1 Grafo de cena e laço de renderização — Projeto do Professor

Este é o projeto de referência resolvido pelo professor: a implementação que serve de modelo do que cada grupo deve produzir no Projeto Integrador. Ele existe para ser estudado, discutido e criticado — não para ser copiado. O que está aqui é uma solução defensável, com as decisões explicadas uma a uma; a solução de cada grupo será outra, e terá de ser defendida do mesmo jeito.

1.1 Visão geral

O módulo em que a Bancada finalmente aparece, e em que quase nada do que importa é visível.

Dois módulos declarando e sondando, e nenhum triângulo desenhado. Agora há oficina: a bancada, o suporte, as cinco peças e um painel preso ao tampo. A imagem é modesta. O que o módulo entrega está atrás dela.

O que ele entrega é a estrutura sobre a qual todo o resto se apoia. A cena passa a ser uma árvore, o laço passa a andar contra o relógio, e o custo do quadro passa a ter teto declarado antes de existir conteúdo pesado para culpar.

Nenhuma dessas três coisas se vê olhando a tela. Uma cena montada como lista de coordenadas absolutas produz exatamente a mesma imagem que esta. É por isso que o módulo cobra as três agora, enquanto a oficina tem cinco peças e dá para conferir tudo a olho.

Este é também o módulo em que o code-along carrega o maior peso do percurso. A árvore e o laço são construídos ao vivo, decisão a decisão, com a turma digitando junto. O arquivo que fica é este.

O código nasce em uma pasta nova de núcleo, ao lado das que já existiam. A declaração de domínio do primeiro módulo não é reescrita: ela vira a fonte de quais peças a cena monta, e o compilador passa a cobrar a correspondência que antes dependia de alguém reler a ata.

1.2 A ordem das operações, e o erro que não acusa nada

Duas linhas trocadas de lugar, meio metro de diferença, e nenhuma mensagem de erro.

Cada nó da cena guarda três coisas próprias: onde está, como está girado, em que tamanho. A biblioteca compõe as três sempre na mesma ordem — escala, depois rotação, depois translação — e multiplica o resultado pela matriz do pai.

Inverter duas dessas operações produz uma cena que continua desenhando. Sem erro, sem aviso, sem nada. Há uma peça a meio metro de onde deveria estar e trinta linhas plausíveis para inspecionar.

Por isso a demonstração é código executável, e não parágrafo.

src/bancada/core/transformacao.ts
// ---------------------------------------------------------------------------
// Composição de transformações, e a ordem como conteúdo.
//
// Cada nó da cena guarda translação, rotação e escala próprias, e a biblioteca
// as compõe sempre na mesma ordem: escala primeiro, rotação depois, translação
// por último. A matriz do nó no mundo é a matriz do pai multiplicada por essa.
//
// Inverter duas dessas operações produz uma cena que continua desenhando, sem
// erro algum, com os objetos nos lugares errados. É o defeito mais caro de
// diagnosticar olhando o resultado, porque não há mensagem: há uma peça a meio
// metro de onde deveria estar, e trinta linhas plausíveis para inspecionar.
//
// Por isso a demonstração da ordem é código executável, e não parágrafo. O
// número que ela devolve é a distância entre os dois resultados, e ele encerra a
// discussão sem apelo a intuição.
// ---------------------------------------------------------------------------

import { Matrix4, Quaternion, Vector3, MathUtils } from 'three';

export interface ComparacaoDeOrdem {
  /** Onde o ponto vai parar quando se gira e depois se translada. */
  readonly girandoPrimeiro: Vector3;
  /** Onde o ponto vai parar quando se translada e depois se gira. */
  readonly transladandoPrimeiro: Vector3;
  /** Distância entre os dois destinos, em metros. */
  readonly distancia: number;
}

/**
 * Aplica as mesmas duas operações a um mesmo ponto, nas duas ordens possíveis, e
 * mede a discrepância.
 *
 * O caso escolhido não é arbitrário: é o do encaixe. Uma peça precisa ir para a
 * posição do socket e assumir a orientação dele. Girar em torno da origem do pai
 * e depois deslocar leva a peça a um lugar; deslocar e depois girar em torno da
 * origem do pai leva a peça a descrever um arco em torno de um ponto onde ela
 * não está. O segundo caso é o erro que se vê em sala.
 */
export function compararOrdem(
  ponto: Vector3,
  deslocamento: Vector3,
  eixo: Vector3,
  anguloEmGraus: number,
): ComparacaoDeOrdem {
  const rotacao: Matrix4 = new Matrix4().makeRotationFromQuaternion(
    new Quaternion().setFromAxisAngle(eixo.clone().normalize(), MathUtils.degToRad(anguloEmGraus)),
  );
  const translacao: Matrix4 = new Matrix4().makeTranslation(
    deslocamento.x,
    deslocamento.y,
    deslocamento.z,
  );

  // recorte:inicio ordem-das-operacoes
  // Multiplicação de matrizes se lê da direita para a esquerda: em `T · R`, a
  // rotação toca o ponto primeiro. É a convenção da biblioteca e da álgebra, e é
  // o ponto em que a leitura ingênua da linha de código engana.
  const girandoPrimeiro: Vector3 = ponto
    .clone()
    .applyMatrix4(new Matrix4().multiplyMatrices(translacao, rotacao));
  const transladandoPrimeiro: Vector3 = ponto
    .clone()
    .applyMatrix4(new Matrix4().multiplyMatrices(rotacao, translacao));
  // recorte:fim ordem-das-operacoes

  return {
    girandoPrimeiro,
    transladandoPrimeiro,
    distancia: girandoPrimeiro.distanceTo(transladandoPrimeiro),
  };
}

/** Formata um vetor em metros, com a precisão que a conferência a olho exige. */
export function emMetros(v: Vector3): string {
  return `(${v.x.toFixed(3)}, ${v.y.toFixed(3)}, ${v.z.toFixed(3)})`;
}

O caso escolhido é o do encaixe, porque é o que a Bancada vai fazer o percurso inteiro. Levar uma peça a trinta centímetros dali e girá-la um quarto de volta. Girando primeiro, ela chega a um lugar; transladando primeiro, ela descreve um arco em torno de um ponto onde não está.

A página imprime os dois destinos e a distância entre eles. O número encerra a discussão sem apelo a intuição, e é ele que a turma vê antes de escrever a primeira transformação própria.

Há uma armadilha de leitura embutida na multiplicação, e ela merece ser dita em voz alta. A conta se lê da direita para a esquerda: em T \cdot R, quem toca o ponto primeiro é a rotação. Quem lê a linha da esquerda para a direita, como se lê texto, entende exatamente o contrário do que o código faz.

1.3 Tarefa 1: Montar a cena como árvore de nós

A imagem não muda; a estrutura por trás dela decide os próximos onze módulos.

Enunciado da tarefa

A cena passa a ser uma hierarquia de objetos, cada um com transformação própria em relação ao pai, em lugar de uma lista de coisas com coordenadas absolutas. A diferença parece contábil neste ponto do percurso e é o que sustenta pegar, encaixar e soltar adiante.

O que fica pronto é a cena mínima do domínio escolhido, montada com pelo menos um nível de aninhamento que exista por razão de projeto: algo que se move junto com outra coisa porque está preso a ela.

1.3.1 O critério que decide cada parentesco

Por que o suporte é filho do tampo, e não da sala? A pergunta parece retórica e não é: as duas montagens desenham a mesma imagem.

O critério é uma frase só. Arrastar a bancada tem de levar junto tudo o que está sobre ela, sem que ninguém escreva uma linha para isso acontecer. O suporte está apoiado no tampo, logo é filho dele. As peças também. O painel também.

Hierarquia montada por acaso — o objeto que virou filho de outro porque foi criado ali — passa despercebida até o dia em que a bancada se move. Aí metade da oficina fica para trás.

src/bancada/core/cena.ts
// ---------------------------------------------------------------------------
// A cena mínima da Bancada, montada como árvore.
//
// A hierarquia daqui não veio da ordem em que os objetos foram criados: veio das
// relações do domínio declarado no primeiro módulo. A sala contém a bancada; a
// bancada carrega o tampo, o suporte, as peças ainda soltas e o painel. Mover a
// bancada move tudo o que está sobre ela, porque na oficina real é isso o que
// acontece — e essa frase é o critério que decide cada parentesco abaixo.
//
// A geometria é paramétrica e propositalmente crua: caixas e cilindros. Modelar
// de verdade, com malha importada, materiais e níveis de detalhe, é assunto dos
// módulos de modelagem e de ativos. Antecipar isso aqui esconderia o conteúdo do
// módulo — a estrutura — atrás de peças bonitas.
//
// A escala é a única coisa que já precisa estar certa: uma unidade é um metro.
// O tampo está a noventa centímetros do chão, altura de bancada de trabalho, e
// essa medida vai ser cobrada de verdade no regime imersivo, quando quem usa
// estiver de pé diante dela em escala real.
// ---------------------------------------------------------------------------

import {
  BoxGeometry,
  Color,
  CylinderGeometry,
  DirectionalLight,
  Group,
  HemisphereLight,
  Mesh,
  MeshStandardMaterial,
  Object3D,
  Scene,
} from 'three';

import { BANCADA, type PecaId } from '../dominio/dominio';

/** Altura do tampo, em metros. Bancada de trabalho de pé. */
export const ALTURA_DO_TAMPO: number = 0.9;

export interface CenaDaBancada {
  readonly sala: Scene;
  readonly bancada: Group;
  readonly suporte: Group;
  /** O eixo do mecanismo: é ele que gira, e o que estiver preso a ele gira junto. */
  readonly eixo: Object3D;
  /** Onde o painel diegético se prende. Preso à bancada, e não à câmera. */
  readonly suporteDoPainel: Object3D;
  /** Cada peça declarada no domínio, com o nó que a representa na cena. */
  readonly pecas: ReadonlyMap<PecaId, Object3D>;
}

function materialFosco(cor: number): MeshStandardMaterial {
  return new MeshStandardMaterial({ color: cor, roughness: 0.7, metalness: 0.1 });
}

/** A forma provisória de cada peça, escolhida só para que ela seja reconhecível. */
function formaDaPeca(id: PecaId): Mesh {
  switch (id) {
    case 'corpo':
      return new Mesh(new BoxGeometry(0.12, 0.06, 0.12), materialFosco(0x8d99ae));
    case 'eixo':
      return new Mesh(new CylinderGeometry(0.012, 0.012, 0.22, 16), materialFosco(0xc9cbd6));
    case 'engrenagem-grande':
      return new Mesh(new CylinderGeometry(0.075, 0.075, 0.016, 24), materialFosco(0xb07d3b));
    case 'engrenagem-pequena':
      return new Mesh(new CylinderGeometry(0.045, 0.045, 0.016, 20), materialFosco(0xd0a05a));
    case 'tampa':
      return new Mesh(new BoxGeometry(0.13, 0.012, 0.13), materialFosco(0x6d7280));
  }
}

/** Onde cada peça repousa sobre o tampo, antes de qualquer montagem. */
function repousoDaPeca(id: PecaId): [number, number, number] {
  switch (id) {
    case 'corpo':
      return [0, 0.055, 0];
    case 'eixo':
      return [-0.32, 0.135, 0.12];
    case 'engrenagem-grande':
      return [0.3, 0.033, 0.1];
    case 'engrenagem-pequena':
      return [0.45, 0.033, -0.05];
    case 'tampa':
      return [-0.15, 0.031, -0.18];
  }
}

export function montarCena(): CenaDaBancada {
  const sala: Scene = new Scene();
  sala.name = 'sala';
  sala.background = new Color(0x1b1d24);

  // Duas luzes, e nenhuma a mais. Cada luz custa em todo material que a recebe,
  // e o orçamento deste módulo não é uma formalidade: é o teto que a máquina
  // mais modesta da turma impõe.
  const ambiente: HemisphereLight = new HemisphereLight(0xdfe6f5, 0x2a2c33, 1.1);
  ambiente.name = 'luz-ambiente';
  sala.add(ambiente);

  const direcional: DirectionalLight = new DirectionalLight(0xffffff, 1.4);
  direcional.name = 'luz-direcional';
  direcional.position.set(1.2, 2.4, 1.0);
  sala.add(direcional);

  const bancada: Group = new Group();
  bancada.name = 'bancada';
  sala.add(bancada);

  const tampo: Mesh = new Mesh(new BoxGeometry(1.4, 0.05, 0.8), materialFosco(0x5c4a35));
  tampo.name = 'tampo';
  tampo.position.y = ALTURA_DO_TAMPO;
  bancada.add(tampo);

  const pe: Mesh = new Mesh(
    new BoxGeometry(1.2, ALTURA_DO_TAMPO - 0.05, 0.6),
    materialFosco(0x3f3428),
  );
  pe.name = 'cavalete';
  pe.position.y = (ALTURA_DO_TAMPO - 0.05) / 2;
  bancada.add(pe);

  // recorte:inicio cena-parentesco-suporte
  // O suporte é filho do tampo: ele está apoiado nele, e arrastar a bancada tem
  // de levá-lo junto sem que ninguém escreva uma linha para isso acontecer.
  const suporte: Group = new Group();
  suporte.name = 'suporte';
  suporte.position.set(0, 0.025, 0);
  tampo.add(suporte);
  // recorte:fim cena-parentesco-suporte

  const base: Mesh = new Mesh(new BoxGeometry(0.34, 0.02, 0.34), materialFosco(0x2f333d));
  base.name = 'base-do-suporte';
  base.position.y = 0.01;
  suporte.add(base);

  const pecas: Map<PecaId, Object3D> = new Map<PecaId, Object3D>();
  // recorte:inicio cena-pecas-nascem-soltas
  for (const peca of BANCADA.pecas) {
    const no: Mesh = formaDaPeca(peca.id);
    no.name = peca.id;
    const [x, y, z] = repousoDaPeca(peca.id);
    no.position.set(x, y, z);
    // As peças nascem sobre o tampo, e não dentro do suporte. A montagem é o que
    // vai movê-las de um pai para o outro, e nascer no destino apagaria
    // justamente a operação que o módulo existe para ensinar.
    tampo.add(no);
    pecas.set(peca.id, no);
  }
  // recorte:fim cena-pecas-nascem-soltas

  const corpo: Object3D | undefined = pecas.get('corpo');
  const eixo: Object3D | undefined = pecas.get('eixo');
  if (corpo === undefined || eixo === undefined) {
    throw new Error('O domínio não declara o corpo ou o eixo, e a cena não se monta sem eles.');
  }

  const suporteDoPainel: Object3D = new Object3D();
  suporteDoPainel.name = 'suporte-do-painel';
  // Preso ao tampo, na borda de trás, virado para quem trabalha. Painel preso à
  // câmera acompanharia a cabeça e deixaria de ser objeto do mundo — no visor,
  // texto grudado no rosto é a receita conhecida de desconforto.
  suporteDoPainel.position.set(0.0, 0.025, -0.36);
  tampo.add(suporteDoPainel);

  return { sala, bancada, suporte, eixo, suporteDoPainel, pecas };
}

Três decisões deste arquivo merecem justificativa.

A primeira é a escala. Uma unidade é um metro, e o tampo está a 0,9 m do chão, que é altura de bancada de trabalho de pé. Nenhum número desses importa hoje. Todos importam no regime imersivo, quando quem usa estiver de pé diante da bancada em tamanho real, e corrigir escala depois significa refazer cada medida da cena.

A segunda é a geometria crua. Caixas e cilindros, nada importado, nada texturizado. Modelagem de verdade é assunto dos módulos seguintes, e antecipá-la aqui esconderia o conteúdo deste — a estrutura — atrás de peças bonitas.

A terceira é onde as peças nascem. Elas nascem soltas sobre o tampo, e não já encaixadas no suporte. Nascer no destino apagaria justamente a operação que o módulo existe para ensinar.

1.3.2 O palco, e o ajuste que se faz a cada quadro

src/bancada/core/palco.ts
// ---------------------------------------------------------------------------
// O palco: superfície de desenho, câmera e o ajuste ao tamanho disponível.
//
// Duas decisões deste arquivo têm consequência direta no orçamento do módulo.
//
// A primeira é o limite da densidade de pixels. Aparelhos de tela densa pedem
// mais de dois pixels físicos por pixel de layout, e o custo de desenhar cresce
// com a área — dobrar a densidade quadruplica o trabalho. Como o teto de
// hardware desta disciplina é uma máquina de vídeo integrado, a densidade é o
// primeiro lugar onde o orçamento estoura, e o corte tem de estar declarado
// antes de haver cena pesada para culpar.
//
// A segunda é conferir o tamanho a cada quadro, em vez de escutar o evento de
// redimensionamento da janela. O evento cobre um caso; o ambiente tem quatro —
// janela redimensionada, celular girado, barra do navegador que aparece e some,
// e a saída de uma sessão imersiva, que devolve o desenho ao canvas com outra
// medida e sem disparar evento de janela nenhum.
// ---------------------------------------------------------------------------

import { PerspectiveCamera, Scene, WebGLRenderer } from 'three';

/** Teto da densidade de pixels. Acima disto o ganho visual não paga o custo. */
const DENSIDADE_MAXIMA: number = 2;

export interface Palco {
  readonly renderer: WebGLRenderer;
  readonly camera: PerspectiveCamera;
  /** Ajusta o alvo de desenho ao canvas. Devolve `true` quando algo mudou. */
  ajustar(): boolean;
  desenhar(cena: Scene): void;
}

export function montarPalco(canvas: HTMLCanvasElement): Palco {
  // O canal de transparência entra aqui por causa do registro por marcador. Sem
  // sessão XR não há compositor do aparelho para pôr a imagem da câmera atrás da
  // cena: quem compõe é a página, empilhando o vídeo sob a superfície de desenho,
  // e uma superfície opaca esconde o vídeo por completo. O sintoma é cruel — a
  // câmera liga, a luz do aparelho acende, a detecção funciona, e a tela mostra
  // apenas a cena sobre o fundo cinza de sempre.
  const renderer: WebGLRenderer = new WebGLRenderer({ canvas, antialias: true, alpha: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, DENSIDADE_MAXIMA));

  // A câmera olha a bancada de pé, de quem chega para trabalhar nela: altura de
  // olho, um metro e meio à frente. O regime não imersivo ganha câmera orbital
  // mais adiante; aqui ela é fixa de propósito, porque o que este módulo tem de
  // demonstrar é a hierarquia se movendo, e câmera que se move junto confunde os
  // dois movimentos.
  const camera: PerspectiveCamera = new PerspectiveCamera(55, 1, 0.05, 50);
  camera.position.set(0, 1.55, 1.5);
  camera.lookAt(0, 0.95, 0);

  function ajustar(): boolean {
    const densidade: number = Math.min(window.devicePixelRatio, DENSIDADE_MAXIMA);
    const largura: number = Math.max(1, Math.floor(canvas.clientWidth * densidade));
    const altura: number = Math.max(1, Math.floor(canvas.clientHeight * densidade));

    if (canvas.width === largura && canvas.height === altura) {
      return false;
    }

    // O terceiro argumento em falso impede a biblioteca de escrever largura e
    // altura no estilo do elemento. Quem manda no tamanho em tela é a folha de
    // estilo; aqui só se acerta o tamanho do buffer de desenho.
    renderer.setSize(largura, altura, false);
    camera.aspect = largura / altura;
    camera.updateProjectionMatrix();
    return true;
  }

  function desenhar(cena: Scene): void {
    renderer.render(cena, camera);
  }

  return { renderer, camera, ajustar, desenhar };
}

Duas escolhas aqui têm consequência direta no orçamento, e a primeira é a densidade de pixels. Telas densas pedem mais de dois pixels físicos por pixel de layout, e o custo de desenhar cresce com a área. Dobrar a densidade quadruplica o trabalho.

Como o teto de hardware desta disciplina é uma máquina de vídeo integrado, a densidade é o primeiro lugar onde o orçamento estoura. O corte fica declarado antes de haver cena pesada a que atribuir a culpa.

A segunda é conferir o tamanho a cada quadro, em vez de escutar o evento de redimensionamento da janela. O evento cobre um caso e o ambiente tem quatro: janela redimensionada, celular girado, barra do navegador que aparece e some, e a saída de uma sessão imersiva — que devolve o desenho ao canvas com outra medida e sem disparar evento de janela nenhum.

1.3.3 Onde é fácil errar, e como conferir

O erro previsível desta tarefa é montar a árvore pela ordem de criação e não perceber. Ele não produz sintoma, e a tutoria não o encontra olhando a tela.

Encontra-se perguntando. Por que este objeto é filho daquele? A pergunta é barata e revela hierarquia acidental na primeira resposta hesitante.

A conferência mecânica é a árvore impressa. O ambiente escreve a estrutura na página, um nível por recuo, e ler seis linhas de indentação custa segundos. O que se procura ali é qualquer peça pendurada na raiz, que é onde o acaso costuma deixá-la.

1.4 Tarefa 2: Reparentar sem recalcular à mão

Uma identidade de três símbolos, e o módulo inteiro de manipulação apoiado nela.

Enunciado da tarefa

Prender um objeto a outro pode ser feito recalculando a posição dele a cada quadro ou mudando de quem ele é filho. A segunda forma é uma operação só, e ela nunca sai de sincronia.

A tarefa fecha quando um objeto da cena troca de pai preservando a posição que ocupava no mundo, sem que nenhuma coordenada seja ajustada manualmente. Essa operação é a que a montagem vai consumir, e vale escrevê-la enquanto a cena ainda é pequena e dá para conferir a olho.

1.4.1 Três linhas de álgebra, e a razão de elas estarem aqui

A solução ingênua funciona. Copiar a cada quadro a posição do objeto a que a peça deveria estar presa produz, hoje, exatamente a mesma imagem. É por isso que ela sobrevive: nada no resultado a denuncia.

Ela cobra depois. Cobra quando o objeto intermediário também se move, quando a peça precisa ser solta, e quando alguém inverte a ordem de atualização e a cópia passa a chegar um quadro atrasada.

A operação correta cabe em uma identidade. A transformação local que preserva o mundo é a do mundo vista de dentro do novo pai:

M_{\text{local}} = M_{\text{pai}}^{-1} \cdot M_{\text{mundo}}

src/bancada/core/hierarquia.ts
// ---------------------------------------------------------------------------
// Reparentagem: trocar de pai preservando o lugar no mundo.
//
// Prender uma peça a outra tem duas soluções. A primeira é recalcular a posição
// da peça a cada quadro, copiando a do objeto a que ela deveria estar presa. A
// segunda é mudar de quem ela é filha, uma vez, e nunca mais pensar no assunto.
//
// As duas produzem a mesma imagem hoje, e é por isso que a primeira sobrevive:
// nada no resultado denuncia a escolha. Ela cobra depois — quando o objeto
// intermediário também se move, quando a peça precisa ser solta, quando alguém
// inverte a ordem de atualização e a cópia passa a chegar um quadro atrasada.
//
// A operação está aqui em três linhas de álgebra porque essas três linhas SÃO o
// conteúdo do módulo: a matriz local que preserva o mundo é a matriz do mundo
// vista de dentro do novo pai, `M_local = M_pai⁻¹ · M_mundo`. A biblioteca
// oferece a mesma operação pronta; o que a versão daqui acrescenta é a medida do
// desvio, e é ela que transforma "confio que preservou" em número conferível.
// ---------------------------------------------------------------------------

import { Matrix4, Object3D, Vector3 } from 'three';

/** Reaproveitados entre chamadas: alocar vetor por quadro é lixo que o coletor cobra. */
const matrizLocal: Matrix4 = new Matrix4();
const posicaoAntes: Vector3 = new Vector3();
const posicaoDepois: Vector3 = new Vector3();

// recorte:inicio reparentagem-preserva-mundo
/**
 * Torna `filho` filho de `novoPai` sem que ele saia do lugar em que estava no
 * mundo. Devolve o desvio residual em metros: o erro de arredondamento da conta,
 * medido nesta cena entre zero e 3e-16 m, ou seja, no limite do ponto flutuante.
 *
 * Desvio grande não é falha desta função: é sinal de que alguma matriz do
 * caminho até a raiz estava desatualizada quando a conta foi feita.
 */
export function reparentar(filho: Object3D, novoPai: Object3D): number {
  // Sem esta atualização a conta usa a matriz do quadro anterior, e o objeto
  // pula para onde ele estava, não para onde está.
  filho.updateWorldMatrix(true, false);
  novoPai.updateWorldMatrix(true, false);

  posicaoAntes.setFromMatrixPosition(filho.matrixWorld);

  matrizLocal.copy(novoPai.matrixWorld).invert().multiply(filho.matrixWorld);

  novoPai.add(filho);
  matrizLocal.decompose(filho.position, filho.quaternion, filho.scale);

  filho.updateWorldMatrix(true, false);
  posicaoDepois.setFromMatrixPosition(filho.matrixWorld);

  return posicaoAntes.distanceTo(posicaoDepois);
}
// recorte:fim reparentagem-preserva-mundo

/** Posição do nó no mundo, com as matrizes do caminho até a raiz atualizadas. */
export function posicaoDeMundo(no: Object3D, destino: Vector3): Vector3 {
  no.updateWorldMatrix(true, false);
  return destino.setFromMatrixPosition(no.matrixWorld);
}

/**
 * Desenha a árvore em texto, um nível por recuo. Serve à conferência a olho e ao
 * painel: hierarquia montada por acaso — objeto que virou filho de outro porque
 * foi criado ali — aparece na indentação antes de aparecer no comportamento.
 */
export function descreverArvore(raiz: Object3D, profundidadeMaxima: number = 4): string[] {
  const linhas: string[] = [];

  function percorrer(no: Object3D, nivel: number): void {
    if (nivel > profundidadeMaxima) {
      return;
    }
    const nome: string = no.name === '' ? `<sem nome: ${no.type}>` : no.name;
    linhas.push(`${'  '.repeat(nivel)}${nome}`);
    for (const filho of no.children) {
      percorrer(filho, nivel + 1);
    }
  }

  percorrer(raiz, 0);
  return linhas;
}

A biblioteca de renderização oferece essa mesma operação pronta, e usá-la seria legítimo. O que a versão daqui acrescenta é o desvio medido: ela devolve, em metros, a distância entre onde o objeto estava e onde ficou. Isso transforma a confiança em um número que aparece na página.

O desvio esperado é zero, ou um resíduo abaixo do que o ponto flutuante de 64 bits distingue: o arredondamento da conta, e nada mais. Desvio grande sinaliza que alguma matriz do caminho até a raiz estava desatualizada quando a conta foi feita, e por isso as duas primeiras linhas do corpo atualizam esse caminho antes de qualquer coisa.

1.4.2 A demonstração que parece errada, e é a prova

A oficina tem um botão que prende a engrenagem grande ao eixo. Ao tocá-lo, a engrenagem não se move. Ela continua deitada no tampo, a trinta centímetros do eixo, exatamente onde estava.

E então começa a girar em torno dele, a distância.

O efeito é estranho e é exatamente o que se quer mostrar. A reparentagem trocou a quem a peça pertence, e não onde ela está. O encaixe — trazer a peça até o socket e assentá-la lá — é outra operação, de outro módulo, e confundir as duas é o que faz um grupo escrever a montagem inteira dentro da função errada.

O botão também desfaz. Soltar devolve a engrenagem ao tampo, de novo sem que ela saia do lugar, e o diário registra as duas posições e o desvio. É a mesma função, no sentido oposto, e ver isso reduz a operação ao que ela é.

1.4.3 Onde é fácil errar, e como conferir

Este é o ponto de atenção do módulo, e ele cobra tarde. O grupo que recalcula posição a cada quadro consegue um resultado idêntico agora, e por isso não vê motivo para mudar.

O preço aparece no módulo de manipulação, quando pegar e encaixar exigem exatamente essa operação. A correção ali custa reescrever o que já funcionava, e o grupo a essa altura tem razão em resistir.

A conferência é o desvio impresso. Prender, ler o número, soltar, ler de novo. Qualquer valor acima de um milímetro significa que a peça pulou, e peça que pula na reparentagem vai pular no encaixe diante da turma.

1.5 Tarefa 3: Fazer o laço andar contra o relógio

O teto entra antes do conteúdo, e essa ordem é a decisão do módulo.

Enunciado da tarefa

O laço de renderização passa a avançar a cena pelo tempo transcorrido, e não pelo número de quadros desenhados. Sem isso o ambiente roda em velocidades diferentes em máquinas diferentes, e o problema aparece primeiro no aparelho de outra pessoa.

O que fica pronto é o laço com relógio próprio e um indicador do custo do quadro exibido na própria cena. O orçamento entra aqui, no começo, para que o teto exista antes de haver conteúdo pesado a caber nele.

1.5.1 O relógio, e o salto que ele se recusa a entregar

src/bancada/core/relogio.ts
// ---------------------------------------------------------------------------
// O relógio do ambiente.
//
// A partir deste módulo a cena avança pelo tempo transcorrido, e não pelo número
// de quadros desenhados. A diferença não aparece na máquina de quem escreve o
// código — aparece no aparelho de outra pessoa, que desenha em outra cadência e
// roda o mesmo mundo em outra velocidade.
//
// O relógio faz uma coisa a mais que o da biblioteca de renderização, e é por
// isso que ele existe aqui: ele limita o salto. Quando a aba perde o foco, ou
// quem usa abre o menu do sistema do visor, o laço para de ser chamado. Ao
// voltar, o intervalo real desde o último quadro pode ser de vários segundos, e
// entregar esse número à cena faz o mecanismo girar meia volta de uma vez.
// ---------------------------------------------------------------------------

/** O que o relógio entrega a cada quadro. Tudo em segundos. */
export interface Amostra {
  /** Tempo desde o quadro anterior, já limitado pelo teto de salto. */
  readonly delta: number;
  /** Tempo acumulado desde o primeiro quadro, somando os deltas limitados. */
  readonly decorrido: number;
  /** Intervalo real medido, antes do limite. Serve à medição, não à simulação. */
  readonly intervaloReal: number;
  /** Verdadeiro quando o intervalo real excedeu o teto e foi cortado. */
  readonly saltoDescartado: boolean;
}

/** Teto de salto, em segundos. Acima disto a suspensão é tratada como pausa. */
const TETO_DE_SALTO_PADRAO: number = 0.1;

export class Relogio {
  private readonly tetoDeSalto: number;
  private ultimoInstanteMs: number | undefined = undefined;
  private decorrido: number = 0;

  constructor(tetoDeSalto: number = TETO_DE_SALTO_PADRAO) {
    this.tetoDeSalto = tetoDeSalto;
  }

  /**
   * Recebe o instante que o laço de animação informa, em milissegundos, e
   * devolve a amostra do quadro.
   *
   * O primeiro quadro tem delta zero de propósito: não existe intervalo anterior
   * a medir, e inventar um valor plausível aqui é a origem de um solavanco na
   * primeira imagem que o ambiente mostra.
   */
  avancar(instanteMs: number): Amostra {
    const anterior: number | undefined = this.ultimoInstanteMs;
    this.ultimoInstanteMs = instanteMs;

    if (anterior === undefined) {
      return { delta: 0, decorrido: 0, intervaloReal: 0, saltoDescartado: false };
    }

    // recorte:inicio relogio-teto-de-salto
    const intervaloReal: number = (instanteMs - anterior) / 1000;
    const saltoDescartado: boolean = intervaloReal > this.tetoDeSalto;
    const delta: number = saltoDescartado ? this.tetoDeSalto : intervaloReal;
    this.decorrido += delta;

    return { delta, decorrido: this.decorrido, intervaloReal, saltoDescartado };
    // recorte:fim relogio-teto-de-salto
  }

  /** Zera a contagem sem destruir o relógio. Usado ao trocar de regime. */
  reiniciar(): void {
    this.ultimoInstanteMs = undefined;
    this.decorrido = 0;
  }
}

A biblioteca já traz um relógio, e ele não faz uma coisa que este faz: limitar o salto. Quando a aba perde o foco, ou quando quem usa abre o menu do sistema do visor, o laço deixa de ser chamado. Ao voltar, o intervalo real desde o último quadro pode ser de vários segundos.

Entregar esse número à cena faz o mecanismo girar meia volta de uma vez. Em uma engrenagem, o efeito é feio. Em um objeto que quem usa está segurando, vira um solavanco no campo de visão. Aí o preço deixa de ser estético e o corpo cobra a conta.

O primeiro quadro tem intervalo zero de propósito. Não existe intervalo anterior a medir, e inventar um valor plausível ali é a origem de um solavanco na primeira imagem que o ambiente mostra.

1.5.2 O laço, e uma decisão tomada três módulos antes do motivo

Qual mecanismo pede o próximo quadro? A pergunta parece administrativa e é a de maior consequência deste arquivo.

O laço de animação da janela serve à página comum e não funciona dentro de uma sessão imersiva. O visor tem cadência própria, e quem entrega o quadro dele é a sessão, não a janela. A biblioteca oferece um laço que troca de fonte sozinho quando a sessão começa, e é esse que adotamos agora.

src/bancada/core/laco.ts
// ---------------------------------------------------------------------------
// O laço de renderização.
//
// A decisão que mais pesa aqui é qual mecanismo pede o próximo quadro, e ela é
// tomada três módulos antes de o motivo aparecer. O laço de animação da janela
// serve à página comum e NÃO funciona dentro de uma sessão imersiva: o visor
// tem cadência própria, e quem entrega o quadro dele é a sessão XR, não a
// janela. A biblioteca de renderização oferece um laço que troca de fonte
// sozinho quando a sessão começa, e é esse que adotamos agora.
//
// Adotá-lo desde já custa nada e evita o retrabalho que se vê em sala: o
// ambiente inteiro escrito em torno do laço da janela, funcionando no desktop, e
// congelado no visor — com a página respondendo normalmente, o que faz o
// diagnóstico apontar para todo lado menos para o laço.
//
// A ordem dentro do quadro também não é livre. Ajusta-se o alvo de desenho,
// mede-se o tempo, avança-se a cena e só então se desenha. Desenhar antes de
// avançar entrega ao olho o estado do quadro anterior — um atraso de um quadro
// que ninguém percebe no desktop e que, no visor, é latência somada à que o
// aparelho já tem.
// ---------------------------------------------------------------------------

import type { Scene } from 'three';

import type { Palco } from './palco';
import { Relogio, type Amostra } from './relogio';
import { Orcamento } from './orcamento';

/**
 * O que o ambiente faz a cada quadro, antes de a imagem ser desenhada.
 *
 * O segundo parâmetro chegou no módulo aumentado, e é o quadro da sessão XR.
 * Ele carrega o que só pode ser perguntado DENTRO do quadro — a pose da cabeça,
 * a resposta da consulta de superfície, a posição atual de uma âncora —, e a
 * plataforma o invalida assim que o quadro termina. Guardá-lo para consultar
 * depois é o erro clássico daqui: a consulta lança exceção, e o rastro aponta
 * para o módulo que consultou, nunca para quem guardou.
 *
 * Fora de sessão ele simplesmente não existe, e é por isso que o tipo admite a
 * ausência: a biblioteca chama o laço da janela com um argumento a menos.
 */
export type PassoDoQuadro = (amostra: Amostra, quadroXR: XRFrame | undefined) => void;

export interface Laco {
  aoPasso(passo: PassoDoQuadro): void;
  iniciar(): void;
  parar(): void;
}

export function montarLaco(palco: Palco, cena: Scene, relogio: Relogio, orcamento: Orcamento): Laco {
  const passos: PassoDoQuadro[] = [];

  // O tipo do segundo parâmetro que a biblioteca declara NÃO admite ausência, e
  // a execução dela admite: fora de sessão a chamada vem com um argumento a
  // menos. Declarar aqui o que de fato chega é o que permite verificar a
  // ausência adiante sem que o verificador considere a verificação supérflua e
  // deixe passar o acesso que estoura no desktop.
  // recorte:inicio laco-envelope-do-quadro
  function quadro(instanteMs: number, quadroXR: XRFrame | undefined): void {
    const inicio: number = performance.now();

    palco.ajustar();
    const amostra: Amostra = relogio.avancar(instanteMs);

    for (const passo of passos) {
      passo(amostra, quadroXR);
    }

    palco.desenhar(cena);

    // As contagens de desenho são zeradas pela própria biblioteca no início de
    // cada renderização, então ler aqui é ler o quadro que acabou de sair — e
    // não a soma do percurso inteiro.
    const custoMs: number = performance.now() - inicio;
    orcamento.registrar(
      custoMs,
      amostra.intervaloReal * 1000,
      palco.renderer.info.render.calls,
      palco.renderer.info.render.triangles,
    );
  }
  // recorte:fim laco-envelope-do-quadro

  return {
    aoPasso(passo: PassoDoQuadro): void {
      passos.push(passo);
    },
    // recorte:inicio laco-quem-pede-o-quadro
    iniciar(): void {
      palco.renderer.setAnimationLoop(quadro);
    },
    parar(): void {
      palco.renderer.setAnimationLoop(null);
      relogio.reiniciar();
    },
    // recorte:fim laco-quem-pede-o-quadro
  };
}

Adotá-lo desde já não custa nada e evita o retrabalho mais comum de sala: o ambiente inteiro escrito em torno do laço da janela, funcionando no desktop, e congelado no visor. A página responde normalmente, o que faz o diagnóstico apontar para todo lado menos para o laço.

A ordem dentro do quadro também não é livre. Ajusta-se o alvo de desenho, mede-se o tempo, avança-se a cena, desenha-se. Desenhar antes de avançar entrega ao olho o estado do quadro anterior — um atraso que ninguém percebe no desktop e que, no visor, soma à latência que o aparelho já tem.

1.5.3 O envelope, e de que aparelho vem o teto

Cada quadro é um envelope de onze milissegundos, e o que não couber nele não fica para depois: some.

De onde saiu esse número? De 90 imagens por segundo, que é a cadência do visor. E aqui há uma sutileza que o plano de aulas resolve em uma frase e que vale abrir.

O teto tem duas metades e elas vêm de aparelhos diferentes. O tempo disponível vem do aparelho de maior cadência, que é o visor: 11,1 ms. O trabalho que cabe nesse tempo vem da máquina mais fraca, que é o desktop de vídeo integrado do laboratório. Orçar pelo desktop sozinho daria 16,7 ms de folga aparente e estouro garantido no visor — que é justamente onde o estouro produz mal-estar físico, e não uma animação menos lisa.

src/bancada/core/orcamento.ts
// ---------------------------------------------------------------------------
// O orçamento por quadro.
//
// Ele entra agora, antes de a cena ter conteúdo pesado, e essa ordem é a decisão
// de projeto deste arquivo. Teto declarado depois que o ambiente já está montado
// não é orçamento: é laudo do que já foi gasto, e a essa altura cortar custa
// remodelar.
//
// O que se mede aqui são duas grandezas diferentes, e confundi-las produz
// diagnóstico errado. O CUSTO é o tempo que o nosso trabalho de quadro consome
// na CPU — é o que escrevemos e o que podemos cortar. O INTERVALO é o tempo real
// entre duas imagens entregues — é o que o corpo de quem usa percebe, e nele
// entram a GPU, o compositor do sistema e tudo o mais que dividimos o aparelho
// com. Custo baixo com intervalo alto é sintoma de gargalo fora do nosso código.
// ---------------------------------------------------------------------------

// recorte:inicio orcamento-dois-tetos
/** Teto do desktop do laboratório, em milissegundos: sessenta imagens por segundo. */
export const TETO_DESKTOP_MS: number = 16.7;

/** Teto do visor, em milissegundos: noventa imagens por segundo. */
export const TETO_VISOR_MS: number = 11.1;
// recorte:fim orcamento-dois-tetos

export interface LeituraDoOrcamento {
  readonly tetoMs: number;
  readonly quadrosMedidos: number;
  readonly custoMedioMs: number;
  readonly intervaloMedioMs: number;
  /** O pior intervalo da janela observada. É ele que produz o engasgo sentido. */
  readonly piorIntervaloMs: number;
  readonly quadrosAcimaDoTeto: number;
  readonly chamadasDeDesenho: number;
  readonly triangulos: number;
}

/** Quantos quadros a janela de observação guarda. */
const JANELA_PADRAO: number = 120;

export class Orcamento {
  private readonly tetoMs: number;
  private readonly custos: Float64Array;
  private readonly intervalos: Float64Array;
  private proximo: number = 0;
  private preenchidos: number = 0;
  private chamadasDeDesenho: number = 0;
  private triangulos: number = 0;

  constructor(tetoMs: number, janela: number = JANELA_PADRAO) {
    this.tetoMs = tetoMs;
    this.custos = new Float64Array(janela);
    this.intervalos = new Float64Array(janela);
  }

  /**
   * Registra o quadro recém-terminado. O buffer é circular e de tamanho fixo
   * porque a alternativa — acumular tudo e tirar a média do percurso inteiro —
   * esconde exatamente o que interessa: a média do ambiente carregado dilui o
   * engasgo de um segundo atrás até ele desaparecer do número.
   */
  registrar(custoMs: number, intervaloMs: number, chamadas: number, triangulos: number): void {
    // recorte:inicio orcamento-janela-circular
    this.custos[this.proximo] = custoMs;
    this.intervalos[this.proximo] = intervaloMs;
    this.proximo = (this.proximo + 1) % this.custos.length;
    if (this.preenchidos < this.custos.length) {
      this.preenchidos += 1;
    }
    this.chamadasDeDesenho = chamadas;
    this.triangulos = triangulos;
    // recorte:fim orcamento-janela-circular
  }

  ler(): LeituraDoOrcamento {
    if (this.preenchidos === 0) {
      return {
        tetoMs: this.tetoMs,
        quadrosMedidos: 0,
        custoMedioMs: 0,
        intervaloMedioMs: 0,
        piorIntervaloMs: 0,
        quadrosAcimaDoTeto: 0,
        chamadasDeDesenho: 0,
        triangulos: 0,
      };
    }

    let somaDeCustos: number = 0;
    let somaDeIntervalos: number = 0;
    let pior: number = 0;
    let acima: number = 0;

    for (let i: number = 0; i < this.preenchidos; i += 1) {
      const custo: number = this.custos[i] ?? 0;
      const intervalo: number = this.intervalos[i] ?? 0;
      somaDeCustos += custo;
      somaDeIntervalos += intervalo;
      if (intervalo > pior) {
        pior = intervalo;
      }
      if (intervalo > this.tetoMs) {
        acima += 1;
      }
    }

    return {
      tetoMs: this.tetoMs,
      quadrosMedidos: this.preenchidos,
      custoMedioMs: somaDeCustos / this.preenchidos,
      intervaloMedioMs: somaDeIntervalos / this.preenchidos,
      piorIntervaloMs: pior,
      quadrosAcimaDoTeto: acima,
      chamadasDeDesenho: this.chamadasDeDesenho,
      triangulos: this.triangulos,
    };
  }
}

/**
 * As linhas que o painel exibe dentro da cena. Ficam aqui, e não no painel,
 * porque quem sabe o que cada número significa é quem o mediu — e porque o
 * mesmo texto vai precisar sair também no relatório em página comum.
 */
export function linhasDoOrcamento(leitura: LeituraDoOrcamento): string[] {
  if (leitura.quadrosMedidos === 0) {
    return ['Orçamento: ainda sem quadros medidos.'];
  }
  const proporcaoAcima: number = (leitura.quadrosAcimaDoTeto / leitura.quadrosMedidos) * 100;
  return [
    `Teto do quadro: ${leitura.tetoMs.toFixed(1)} ms`,
    `Intervalo medio: ${leitura.intervaloMedioMs.toFixed(1)} ms  ·  pior: ${leitura.piorIntervaloMs.toFixed(1)} ms`,
    `Custo do nosso trabalho: ${leitura.custoMedioMs.toFixed(2)} ms`,
    `Acima do teto: ${proporcaoAcima.toFixed(0)}% dos ${leitura.quadrosMedidos} quadros observados`,
    `Chamadas de desenho: ${leitura.chamadasDeDesenho}  ·  triangulos: ${leitura.triangulos}`,
  ];
}

O arquivo mede duas grandezas, e confundi-las produz diagnóstico errado. O custo é o tempo que o nosso trabalho consome; é o que escrevemos e o que podemos cortar. O intervalo é o tempo real entre duas imagens; é o que o corpo percebe, e nele entram a placa de vídeo, o compositor do sistema e tudo o mais com que dividimos o aparelho.

Custo baixo com intervalo alto é sintoma de gargalo fora do nosso código. Sem as duas colunas lado a lado, o grupo passa a tarde otimizando a metade que já estava barata.

A janela de observação é curta e circular. A média do percurso inteiro dilui o engasgo de um segundo atrás até ele sumir do número, e o engasgo isolado é exatamente o que o corpo registra.

1.5.4 O painel diegético, na sua primeira versão

No módulo anterior o relatório saía em página comum, e a razão declarada era que ainda não havia mundo onde pendurá-lo. Agora há.

src/bancada/ui/painel.ts
// ---------------------------------------------------------------------------
// A primeira pedra do painel diegético.
//
// No módulo anterior o relatório saía em página comum, e a razão declarada era
// que ainda não havia mundo onde pendurá-lo. Agora há. O painel passa a ser um
// objeto da cena, preso à bancada, lido de dentro do ambiente — que é a única
// forma de um número chegar a quem está com o visor no rosto.
//
// A implementação é a mais simples que sustenta o requisito: uma superfície
// plana com uma textura desenhada por nós, redesenhada algumas vezes por
// segundo. Duas razões para não redesenhar a cada quadro. A primeira é custo:
// enviar textura para a placa é das operações mais caras do quadro, e um painel
// que estoura o orçamento enquanto informa o orçamento seria uma piada de mau
// gosto. A segunda é legibilidade: número que muda sessenta vezes por segundo
// não se lê, borra.
//
// ---------------------------------------------------------------------------
// O que o módulo final acrescentou, e por quê.
//
// O painel nasceu no regime em janela, onde a câmera orbita a bancada e sempre
// olha para ela. Nos outros dois regimes isso deixa de valer, e três defeitos
// apareceram no primeiro ensaio com o visor:
//
// 1. O cartaz plano visto de lado é uma linha. Quem contorna a bancada perde o
//    painel inteiro. A correção é girá-lo em torno do próprio eixo vertical para
//    encarar quem lê — e SÓ em torno dele: inclinar o cartaz para acompanhar a
//    cabeça de quem se abaixa faz um objeto preso à mesa parecer solto no ar, e
//    a única coisa que o painel não pode perder é o pertencimento à bancada.
// 2. Texto de 24 pixels a três metros não se lê. O painel não pode crescer sem
//    deixar de ser um cartaz de bancada, e não pode encolher a distância sem
//    mentir sobre a escala — então ele MEDE a própria legibilidade e diz quando
//    ela caiu, em vez de fingir que está sendo lido.
// 3. Linha comprida saía pela borda direita sem aviso. Agora ela quebra, e o que
//    não couber é anunciado como corte em vez de sumir.
//
// A quarta correção é o `chamar()`: no celular, em sessão aumentada, a bancada
// pousa onde a mesa está e o painel pode ficar de costas, atrás dela. Trazê-lo
// para a borda mais próxima de quem lê é gesto explícito, e não automático —
// painel que se move sozinho é a coisa que mais rápido deixa de parecer objeto.
// ---------------------------------------------------------------------------

import {
  CanvasTexture,
  LinearFilter,
  Mesh,
  MeshBasicMaterial,
  Object3D,
  PlaneGeometry,
  Vector3,
} from 'three';

/** Largura do painel em metros. Um cartaz de bancada, não um outdoor. */
const LARGURA_M: number = 0.62;
const ALTURA_M: number = 0.34;

/** Resolução da textura, em pixels. Múltiplo da proporção física acima. */
const LARGURA_PX: number = 620;
const ALTURA_PX: number = 340;

/** Intervalo mínimo entre redesenhos, em segundos. */
const INTERVALO_DE_REDESENHO: number = 0.25;

/** Corpo do texto das linhas, em pixels da textura. */
const CORPO_PX: number = 24;
/** Altura de linha, em pixels da textura. */
const ENTRELINHA_PX: number = 34;
/** Margem lateral, em pixels da textura. Vale dos dois lados. */
const MARGEM_PX: number = 24;

/**
 * Abaixo de quantos minutos de arco o texto deixa de ser lido.
 *
 * É DECISÃO DE PROJETO, não norma citada. O valor saiu do único ensaio que se
 * pôde fazer sem depender do rodízio dos três visores: no desktop, afastando o
 * ponto de vista até o texto do painel parar de ser lido por três pessoas
 * diferentes com a mesma fonte e o mesmo corpo. Deu por volta de vinte minutos
 * de arco, e vinte é o que está aqui.
 *
 * O ensaio dentro do visor CONTINUA A FAZER. Ele pode mover este número, porque
 * a densidade de pixels do visor e a distorção da lente não entram na conta do
 * ângulo — e é por isso que o painel informa o valor medido ao lado do limiar,
 * em vez de devolver apenas "legível" ou "ilegível".
 */
export const LIMIAR_DE_LEITURA_MINUTOS: number = 20;

/** O que o painel responde sobre a própria leitura, no instante da consulta. */
export interface Legibilidade {
  readonly distanciaM: number;
  readonly alturaAparenteMinutos: number;
  readonly legivel: boolean;
  /** Quantas linhas o último desenho não conseguiu mostrar. */
  readonly linhasCortadas: number;
}

export interface Painel {
  readonly no: Mesh;
  /** Entrega o texto do painel. O redesenho só acontece quando vale a pena. */
  atualizar(linhas: readonly string[], decorrido: number): void;
  /**
   * Gira o painel em torno do eixo vertical para encarar quem lê. Chamada a
   * cada quadro; a conta é de três linhas e não entra no orçamento de forma
   * mensurável.
   */
  encarar(observador: Object3D): void;
  /** Mede a própria leitura a partir de onde o observador está agora. */
  legibilidade(observador: Object3D): Legibilidade;
  /** Traz o painel para a borda da bancada voltada a quem lê. Gesto explícito. */
  chamar(observador: Object3D): void;
  /** Devolve o painel ao lugar de origem sobre o tampo. */
  devolver(): void;
  chamado(): boolean;
}

export function montarPainel(titulo: string): Painel {
  const tela: HTMLCanvasElement = document.createElement('canvas');
  tela.width = LARGURA_PX;
  tela.height = ALTURA_PX;

  const contexto: CanvasRenderingContext2D | null = tela.getContext('2d');
  if (contexto === null) {
    throw new Error('Este navegador não fornece contexto de desenho para o painel.');
  }
  // A cópia em constante não é adorno: a checagem acima estreita o tipo aqui, e
  // esse estreitamento não sobrevive à entrada na função de desenho.
  const pincel: CanvasRenderingContext2D = contexto;

  const textura: CanvasTexture = new CanvasTexture(tela);
  // Sem os níveis intermediários de detalhe, texto lido de longe cintila; com o
  // filtro linear ele apenas borra, que é o defeito menos ruim dos dois.
  textura.minFilter = LinearFilter;
  textura.generateMipmaps = false;

  const no: Mesh = new Mesh(
    new PlaneGeometry(LARGURA_M, ALTURA_M),
    new MeshBasicMaterial({ map: textura }),
  );
  no.name = 'painel';
  // Material sem iluminação de propósito: painel que escurece quando a luz muda
  // de lugar deixa de ser instrumento de leitura.
  no.position.y = ALTURA_M / 2;

  const posicaoDeOrigem: Vector3 = no.position.clone();

  let ultimoDesenho: number = Number.NEGATIVE_INFINITY;
  let ultimoTexto: string = '';
  let cortadas: number = 0;
  let chamado: boolean = false;

  const doPainel: Vector3 = new Vector3();
  const doObservador: Vector3 = new Vector3();

  /**
   * Quebra a linha que não cabe na largura útil.
   *
   * A medida é feita com a régua do próprio contexto de desenho, e não por
   * contagem de caracteres: a fonte é proporcional, e "iiii" e "MMMM" têm o
   * mesmo número de letras e larguras que diferem em três vezes. Contagem de
   * caracteres é o atalho que faz o painel cortar cedo demais em umas linhas e
   * transbordar em outras.
   */
  function quebrar(linha: string, larguraUtil: number): string[] {
    if (pincel.measureText(linha).width <= larguraUtil) {
      return [linha];
    }
    const partes: string[] = [];
    let atual: string = '';
    for (const palavra of linha.split(' ')) {
      const tentativa: string = atual === '' ? palavra : `${atual} ${palavra}`;
      if (pincel.measureText(tentativa).width <= larguraUtil) {
        atual = tentativa;
        continue;
      }
      if (atual !== '') {
        partes.push(atual);
      }
      atual = palavra;
    }
    if (atual !== '') {
      partes.push(atual);
    }
    return partes;
  }

  function desenhar(linhas: readonly string[]): void {
    pincel.fillStyle = '#11131a';
    pincel.fillRect(0, 0, LARGURA_PX, ALTURA_PX);
    pincel.fillStyle = '#f2f4fa';
    pincel.font = `bold ${CORPO_PX + 6}px system-ui, sans-serif`;
    pincel.fillText(titulo, MARGEM_PX, 52);

    pincel.font = `${CORPO_PX}px system-ui, sans-serif`;
    const larguraUtil: number = LARGURA_PX - MARGEM_PX * 2;

    const quebradas: string[] = [];
    for (const linha of linhas) {
      quebradas.push(...quebrar(linha, larguraUtil));
    }

    let linhaY: number = 100;
    let escritas: number = 0;
    for (const linha of quebradas) {
      if (linhaY > ALTURA_PX - MARGEM_PX) {
        break;
      }
      pincel.fillText(linha, MARGEM_PX, linhaY);
      linhaY += ENTRELINHA_PX;
      escritas += 1;
    }
    cortadas = quebradas.length - escritas;
    textura.needsUpdate = true;
  }

  desenhar([]);

  function atualizar(linhas: readonly string[], decorrido: number): void {
    const texto: string = linhas.join('\n');
    if (decorrido - ultimoDesenho < INTERVALO_DE_REDESENHO || texto === ultimoTexto) {
      return;
    }
    ultimoDesenho = decorrido;
    ultimoTexto = texto;
    desenhar(linhas);
  }

  function distanciaAte(observador: Object3D): number {
    no.getWorldPosition(doPainel);
    observador.getWorldPosition(doObservador);
    return doPainel.distanceTo(doObservador);
  }

  function encarar(observador: Object3D): void {
    no.getWorldPosition(doPainel);
    observador.getWorldPosition(doObservador);
    // Só o componente horizontal entra na conta. Zerar a altura ANTES de mirar é
    // o que mantém o cartaz de pé: mirando o ponto inteiro, ele se inclinaria
    // para acompanhar quem se abaixa, e um objeto preso à mesa não faz isso.
    doObservador.y = doPainel.y;
    no.lookAt(doObservador);
  }

  // diagrama:adiado quem exercita e o regime imersivo, pela composicao do ambiente
  function legibilidade(observador: Object3D): Legibilidade {
    const distancia: number = distanciaAte(observador);
    // Altura física de uma letra: a proporção que ela ocupa na textura, aplicada
    // à altura real da superfície.
    const alturaDaLetraM: number = (CORPO_PX / ALTURA_PX) * ALTURA_M;
    // Ângulo subtendido, em minutos de arco. A distância mínima evita a divisão
    // por zero de quem encosta o rosto no painel dentro do visor — que acontece.
    const radianos: number = 2 * Math.atan(alturaDaLetraM / (2 * Math.max(distancia, 0.05)));
    const minutos: number = (radianos * 180 * 60) / Math.PI;
    return {
      distanciaM: distancia,
      alturaAparenteMinutos: minutos,
      legivel: minutos >= LIMIAR_DE_LEITURA_MINUTOS,
      linhasCortadas: cortadas,
    };
  }

  // diagrama:adiado quem exercita e o regime imersivo, pela composicao do ambiente
  function chamar(observador: Object3D): void {
    // O painel anda no espaço do próprio pai — o suporte preso ao tampo —, e é
    // por isso que a bancada continua sendo o dono dele depois de chamado. Um
    // painel que se soltasse do suporte para ir até quem lê deixaria de ser
    // diegético no instante em que fosse mais útil.
    const pai: Object3D | null = no.parent;
    if (pai === null) {
      return;
    }
    observador.getWorldPosition(doObservador);
    pai.worldToLocal(doObservador);
    doObservador.y = posicaoDeOrigem.y;
    // Meio metro à frente de quem lê, na direção em que ele está, e não em cima
    // dele: colado no rosto o cartaz fica perto demais para ser lido e atrapalha
    // a mão que trabalha.
    const direcao: Vector3 = doObservador.clone().setY(0);
    if (direcao.lengthSq() < 1e-6) {
      return;
    }
    direcao.normalize().multiplyScalar(0.45);
    no.position.set(direcao.x, posicaoDeOrigem.y, direcao.z);
    chamado = true;
  }

  function devolver(): void {
    no.position.copy(posicaoDeOrigem);
    chamado = false;
  }

  return {
    no,
    atualizar,
    encarar,
    legibilidade,
    chamar,
    devolver,
    chamado(): boolean {
      return chamado;
    },
  };
}

/** A medida da leitura em texto, para a folha e para o diário. */
// diagrama:adiado quem exercita e o regime imersivo, pela composicao do ambiente
export function linhasDaLegibilidade(medida: Legibilidade): string[] {
  const linhas: string[] = [
    `Distância de quem lê até o painel: ${medida.distanciaM.toFixed(2)} m.`,
    `Altura aparente da letra: ${medida.alturaAparenteMinutos.toFixed(1)} minutos de arco ` +
      `(limiar adotado: ${LIMIAR_DE_LEITURA_MINUTOS}).`,
    medida.legivel
      ? 'Acima do limiar: o texto está sendo lido desta distância.'
      : 'ABAIXO do limiar: daqui o painel é um borrão. Aproxime-se, ou chame o painel para a ' +
        'borda da bancada — ele não cresce, porque crescer mentiria sobre a escala do mundo.',
  ];
  if (medida.linhasCortadas > 0) {
    linhas.push(
      `${medida.linhasCortadas} linha(s) não couberam no último desenho e foram anunciadas como ` +
        'corte. O texto inteiro está na folha de diagnóstico.',
    );
  }
  linhas.push(
    '',
    'O limiar é decisão de projeto aferida no desktop, e não norma citada. O ensaio dentro do ' +
      'visor continua a fazer: densidade de pixels e distorção da lente não entram na conta do ' +
      'ângulo, e podem mover o número.',
  );
  return linhas;
}

O painel é um objeto da cena, preso ao tampo, virado para quem trabalha. Preso à câmera ele acompanharia a cabeça e deixaria de ser objeto do mundo — e texto grudado no rosto, dentro de um visor, é receita conhecida de desconforto.

Ele redesenha algumas vezes por segundo, não a cada quadro. Enviar textura para a placa é das operações mais caras do quadro, e um painel que estoura o orçamento enquanto informa o orçamento seria uma piada de mau gosto. O segundo motivo é mais simples: número que muda 60 vezes por segundo não se lê, borra.

1.5.5 A composição, e por que ela mora em um arquivo só

src/demos/03_demo.ts
// ---------------------------------------------------------------------------
// Marco 03 — a Bancada passa a desenhar.
//
// Entra a cena como árvore de nós, o laço andando contra o relógio, e o painel
// preso à própria bancada informando quanto custa cada quadro. São três coisas
// que só fazem sentido juntas: a árvore diz o que existe, o relógio diz quanto
// tempo passou de verdade entre um quadro e o outro, e o orçamento diz se o que
// existe cabe no tempo que há.
//
// O eixo gira contra o relógio, e não contra a contagem de quadros: num aparelho
// mais lento a peça giraria devagar, e o giro deixaria de ser uma grandeza do
// mundo para virar uma grandeza da máquina.
//
// Ponto de entrada do MARCO 03, imutável (`specs/marcos-executaveis.md`).
// ---------------------------------------------------------------------------

import { Object3D } from 'three';

import { montarPalco, type Palco } from '../bancada/core/palco';
import { montarCena, type CenaDaBancada } from '../bancada/core/cena';
import { Relogio, type Amostra } from '../bancada/core/relogio';
import { Orcamento, TETO_DESKTOP_MS, linhasDoOrcamento } from '../bancada/core/orcamento';
import { montarLaco, type Laco } from '../bancada/core/laco';
import { descreverArvore, reparentar } from '../bancada/core/hierarquia';
import { montarPainel, type Painel } from '../bancada/ui/painel';
import { montarEstruturaDaCena } from '../bancada/relatorio/estrutura';

// Dezesseis milissegundos e meio é o teto de um quadro a sessenta por segundo.
// O número não é escolha de gosto: é o intervalo que o aparelho concede.
const TETO_DO_QUADRO_MS: number = TETO_DESKTOP_MS;

function criarNo<T extends HTMLElement>(tag: string, pai: HTMLElement, id?: string): T {
  const no: T = document.createElement(tag) as T;
  if (id !== undefined) {
    no.id = id;
  }
  pai.appendChild(no);
  return no;
}

const raiz: HTMLElement = document.getElementById('bancada') ?? document.body;

const titulo: HTMLHeadingElement = criarNo<HTMLHeadingElement>('h1', raiz);
titulo.textContent = 'Bancada — a oficina como árvore, e o custo de cada quadro';

const canvas: HTMLCanvasElement = criarNo<HTMLCanvasElement>('canvas', raiz, 'cena');
const botaoPrender: HTMLButtonElement = criarNo<HTMLButtonElement>('button', raiz, 'prender');
botaoPrender.type = 'button';
botaoPrender.textContent = 'Prender a engrenagem ao eixo';

const raizEstrutura: HTMLElement = criarNo<HTMLElement>('div', raiz, 'estrutura');

// recorte:inicio marco-03-composicao
const palco: Palco = montarPalco(canvas);
const cena: CenaDaBancada = montarCena();
const relogio: Relogio = new Relogio();
const orcamento: Orcamento = new Orcamento(TETO_DO_QUADRO_MS);
const laco: Laco = montarLaco(palco, cena.sala, relogio, orcamento);

// O painel é diegético: ele é um objeto dentro da cena, preso à bancada, e não
// um retângulo sobreposto à imagem. Num visor não existe "canto da tela".
const painel: Painel = montarPainel('Custo do quadro');
cena.suporteDoPainel.add(painel.no);
// recorte:fim marco-03-composicao

function atualizarEstrutura(): void {
  montarEstruturaDaCena(raizEstrutura, descreverArvore(cena.sala), [
    'A árvore é percorrida do nó raiz até as folhas, e cada nó carrega a transformação do pai.',
    'Prender a engrenagem ao eixo não a move: troca o pai dela, e a posição de mundo é recalculada a partir do novo pai.',
  ]);
}

atualizarEstrutura();

// Reparentar é a operação que separa uma árvore de cena de uma lista de objetos:
// a engrenagem passa a acompanhar o eixo sem que ninguém copie a rotação dele.
botaoPrender.addEventListener('click', () => {
  const engrenagem: Object3D | undefined = cena.pecas.get('engrenagem-grande');
  if (engrenagem === undefined) {
    return;
  }
  // O retorno de `reparentar` é o desvio residual da conta, em metros, e não uma
  // contagem de níveis da árvore. A notação exponencial é o que torna "no limite
  // do ponto flutuante" legível de longe, projetado.
  const desvioEmMetros: number = reparentar(engrenagem, cena.eixo);
  botaoPrender.textContent = `Presa ao eixo (desvio de ${desvioEmMetros.toExponential(1)} m)`;
  atualizarEstrutura();
});

laco.aoPasso((amostra: Amostra) => {
  // Contra o relógio, não contra a contagem de quadros.
  cena.eixo.rotation.y += amostra.delta * 0.6;
  painel.atualizar(linhasDoOrcamento(orcamento.ler()), amostra.decorrido);
});

laco.iniciar();

Este arquivo não traz conceito novo, e essa é a função dele. Ele existe para que cada um dos outros contenha um só: o relógio não sabe o que é uma bancada, a cena não sabe medir tempo, e o painel não sabe de onde vêm os números que exibe.

Quem os apresenta uns aos outros é a composição, e é o único arquivo que muda quando a composição muda. Nos módulos de regime e de entrada, esse desenho é o que permite trocar de aparelho sem tocar na oficina.

1.5.6 Onde é fácil errar, e como conferir

O erro mais comum é o mais invisível: somar um valor fixo a cada quadro, em vez de multiplicar pelo tempo transcorrido. Na máquina de quem escreveu, funciona. Na máquina ao lado, o mesmo mecanismo gira mais devagar, e ninguém liga uma coisa à outra.

A conferência é direta e não exige instrumento. Abrir o ambiente em duas máquinas de cadências diferentes e cronometrar uma volta do eixo. Se as voltas divergem, o laço está contando quadros.

O segundo erro é declarar o teto e nunca mais olhar para ele. O painel existe contra isso: o número fica na cena, visível durante toda a construção, e a linha que informa a proporção de quadros acima do teto denuncia a peça pesada no dia em que ela entra.

1.6 Por dentro da implementação

Este é o primeiro módulo em que a Bancada desenha, e por isso o acréscimo sobre o anterior é o maior do percurso até aqui. Nove coisas entraram, e cada uma muda o caminho que a execução percorre:

alteração o que ela passou a fazer
cena-arvore a oficina deixa de ser uma lista de objetos e passa a ser uma árvore com parentesco
palco-ajuste o alvo de desenho se confere a cada quadro, em vez de escutar o evento da janela
relogio-salto o tempo entregue à cena é medido, e o salto da suspensão é cortado
laco-quadro alguém passa a pedir o próximo quadro, numa ordem interna que não é livre
orcamento-janela cada quadro é medido numa janela circular de tamanho fixo
reparentagem uma peça troca de pai sem sair do lugar em que está no mundo
arvore-descrita a árvore vira texto, por percurso recursivo
painel-diegetico o instrumento é um objeto dentro da cena, e não um retângulo sobre a imagem
ordem-operacoes a ordem de composição das transformações vira número conferível

A costura com o que já existia é uma linha, e continua sendo o domínio: montarCena() percorre BANCADA.pecas, a mesma lista declarada no primeiro módulo, e cria um nó para cada peça com o identificador dela no campo name. É por isso que os nomes escritos na árvore são exatamente os que a delimitação declarou, e não uma segunda lista mantida à parte.

1.6.1 A cena como árvore, e as peças que nascem soltas

O cenário é a abertura da página do marco: montarCena() roda uma vez, e a árvore sai com quinze linhas. As peças nascem sobre o tampo — não dentro do suporte, onde elas terminam montadas.

flowchart TD
  ini["montarCena()"] --> sala["sala: Scene, nome sala"]
  sala --> luzes["luz-ambiente e luz-direcional"]
  luzes --> banc["bancada: Group, filha da sala"]
  banc --> tampo["tampo e cavalete, filhos da bancada"]
  tampo --> sup["suporte, filho do TAMPO"]
  sup --> laco{"para cada peca de BANCADA.pecas"}
  laco -->|"peca.id"| forma["formaDaPeca(peca.id)"]
  forma --> repouso["repousoDaPeca(peca.id)"]
  repouso --> add["tampo.add(no) — a peca nasce SOLTA"]
  add --> laco
  laco -->|"lista esgotada"| checa{"corpo ou eixo indefinido?"}
  checa -->|"sim"| erro["throw Error"]
  checa -->|"nao"| painel["suporteDoPainel, filho do tampo"]
  painel --> fim["devolve CenaDaBancada"]

formaDaPeca() e repousoDaPeca() decidem por peça, num switch sobre o identificador do domínio, e é isso que mantém a cena e a delimitação amarradas: peça declarada sem forma quebra a compilação, e não a imagem.

sequenceDiagram
  autonumber
  participant demo as 03_demo.ts
  participant cena as core/cena.ts
  participant dom as dominio/dominio.ts
  demo->>cena: montarCena()
  cena->>dom: BANCADA.pecas
  dom-->>cena: 5 pecas declaradas
  loop uma vez por peca
    cena->>cena: formaDaPeca("engrenagem-grande")
    cena-->>cena: Mesh de CylinderGeometry, raio 0.075
    cena->>cena: repousoDaPeca("engrenagem-grande")
    cena-->>cena: posicao de repouso sobre o tampo
  end
  cena-->>demo: CenaDaBancada com sala, eixo, pecas e suporteDoPainel

As peças nascendo sobre o tampo, e o que isso custou. A alternativa era criar cada peça já dentro do suporte, no lugar em que ela termina montada. A cena inicial ficaria mais arrumada e o marco abriria com o mecanismo pronto. O custo é que a operação de montagem deixaria de ter o que fazer: nascer no destino apaga justamente a troca de pai que o módulo existe para mostrar.

1.6.2 O palco que se confere, em vez de escutar o evento

O cenário são dois quadros seguidos: o primeiro, com o canvas ainda no tamanho que o HTML deu; o segundo, com nada mudado. ajustar() devolve true no primeiro e false no segundo, e só o primeiro paga o custo de refazer a projeção.

flowchart TD
  mont["montarPalco(canvas)"] --> lim["setPixelRatio limitado por DENSIDADE_MAXIMA"]
  lim --> ini["ajustar()"]
  ini --> dens["densidade = min(devicePixelRatio, DENSIDADE_MAXIMA)"]
  dens --> med["largura e altura do canvas vezes densidade"]
  med --> cmp{"canvas.width === largura e canvas.height === altura"}
  cmp -->|"sim"| nada["devolve false — nada mudou"]
  cmp -->|"nao"| size["renderer.setSize(largura, altura, false)"]
  size --> asp["camera.aspect = largura / altura"]
  asp --> proj["camera.updateProjectionMatrix()"]
  proj --> mudou["devolve true"]

sequenceDiagram
  autonumber
  participant demo as 03_demo.ts
  participant laco as core/laco.ts
  participant palco as core/palco.ts
  participant cam as PerspectiveCamera
  Note over laco,palco: primeiro quadro, canvas ainda com o tamanho do HTML
  demo->>palco: montarPalco(canvas)
  palco-->>demo: Palco com renderer, camera, ajustar e desenhar
  laco->>palco: ajustar()
  palco->>palco: compara tamanho medido com o do buffer
  palco->>palco: renderer.setSize(largura, altura, false)
  palco->>cam: updateProjectionMatrix()
  cam-->>palco: projecao refeita
  palco-->>laco: true
  Note over laco,palco: quadros seguintes, sem redimensionamento
  laco->>palco: ajustar()
  palco-->>laco: false

Conferir a cada quadro, e o que isso custou. A alternativa era escutar o evento de redimensionamento da janela, que é o que quase todo exemplo faz. O custo é a cobertura: o evento cobre um caso, e o ambiente tem quatro — janela redimensionada, celular girado, barra do navegador que aparece e some, e a saída de uma sessão imersiva, que devolve o desenho ao canvas com outra medida sem disparar evento de janela nenhum. A comparação de dois inteiros por quadro é barata; o quarto caso, diagnosticado a olho, não é.

1.6.3 O relógio, e o salto que ele corta

O cenário é a aba voltando do segundo plano depois de meio minuto. O intervalo real medido é de trinta segundos; o que avancar() entrega à cena é um décimo.

flowchart TD
  ini["avancar(instanteMs)"] --> guarda["anterior = ultimoInstanteMs; ultimoInstanteMs = instanteMs"]
  guarda --> prim{"anterior === undefined"}
  prim -->|"sim, primeiro quadro"| zero["devolve delta 0 e decorrido 0"]
  prim -->|"nao"| real["intervaloReal = (instanteMs - anterior) / 1000"]
  real --> teto{"intervaloReal maior que tetoDeSalto"}
  teto -->|"sim"| corta["delta = tetoDeSalto; saltoDescartado = true"]
  teto -->|"nao"| passa["delta = intervaloReal; saltoDescartado = false"]
  corta --> soma["decorrido += delta"]
  passa --> soma
  soma --> fim["devolve Amostra com delta, decorrido, intervaloReal e saltoDescartado"]

sequenceDiagram
  autonumber
  participant laco as core/laco.ts
  participant rel as core/relogio.ts
  Note over laco,rel: a aba volta do segundo plano depois de 30 s
  laco->>rel: avancar(instanteMs de 30 s depois)
  rel->>rel: intervaloReal = 30.0 s
  rel->>rel: 30.0 maior que tetoDeSalto de 0.1
  rel-->>laco: delta 0.1, intervaloReal 30.0, saltoDescartado true
  Note over laco,rel: o eixo gira 0.06 rad no quadro, e nao 18 voltas
  laco->>rel: avancar(instanteMs do quadro seguinte)
  rel-->>laco: delta 0.016, saltoDescartado false

O teto de salto, e o que ele custou. A alternativa era entregar sempre o intervalo real. O custo aparece exatamente nesse retorno: o primeiro quadro traria trinta segundos, o eixo giraria dezoito voltas de uma vez e a cena reapareceria num estado que ninguém pediu. O intervalo medido continua acessível em intervaloReal, porque a medição precisa dele cru — o que é limitado é a simulação, não o instrumento.

O primeiro quadro com delta zero, e o que isso custou. Não existe intervalo anterior a medir no primeiro quadro, e a alternativa seria devolver um valor plausível. O custo de inventar esse valor é um solavanco na primeira imagem que o ambiente mostra, que é a pior imagem para se ter um solavanco.

1.6.4 O laço, e a ordem dentro do quadro

O cenário é um quadro qualquer do regime estável: ajustar() devolve false, o relógio entrega dezesseis milissegundos, o passo do demo soma pouco menos de um centésimo de radiano ao eixo, e o orçamento registra o custo do trabalho.

flowchart TD
  mont["montarLaco(palco, cena.sala, relogio, orcamento)"] --> ini["iniciar()"]
  ini --> reg["palco.renderer.setAnimationLoop(quadro)"]
  reg --> q["quadro(instanteMs, quadroXR)"]
  q --> t0["inicio = performance.now()"]
  t0 --> aj["palco.ajustar()"]
  aj --> av["relogio.avancar(instanteMs)"]
  av --> passos{"para cada passo registrado por aoPasso()"}
  passos -->|"passo(amostra, quadroXR)"| passos
  passos -->|"lista esgotada"| des["palco.desenhar(cena)"]
  des --> custo["custoMs = performance.now() - inicio"]
  custo --> regs["orcamento.registrar(custoMs, intervaloReal, calls, triangles)"]
  regs --> q
  q -.->|"parar()"| stop["setAnimationLoop(null) e relogio.reiniciar()"]

sequenceDiagram
  autonumber
  participant demo as 03_demo.ts
  participant laco as core/laco.ts
  participant palco as core/palco.ts
  participant rel as core/relogio.ts
  participant orc as core/orcamento.ts
  demo->>laco: montarLaco(palco, cena.sala, relogio, orcamento)
  laco-->>demo: Laco com aoPasso, iniciar e parar
  demo->>laco: aoPasso(passo que gira o eixo)
  demo->>laco: iniciar()
  laco->>palco: setAnimationLoop(quadro)
  Note over laco,orc: um quadro, na ordem que nao e livre
  laco->>palco: ajustar()
  palco-->>laco: false
  laco->>rel: avancar(instanteMs)
  rel-->>laco: delta 0.016 s
  laco->>demo: passo(amostra, undefined)
  demo-->>laco: eixo.rotation.y somado de 0.0099 rad
  laco->>palco: desenhar(cena)
  palco-->>laco: imagem entregue
  laco->>orc: registrar(0.42 ms, 16.6 ms, 12 chamadas, 4380 triangulos)

Ajustar, medir, avançar e só então desenhar. A alternativa é desenhar antes de avançar, que é a ordem em que muita gente escreve o laço da primeira vez. O custo é um atraso de um quadro: o olho recebe o estado anterior. No desktop ninguém percebe; no visor é latência somada à que o aparelho já tem, e ela é sentida antes de ser medida.

O laço da biblioteca, e não o da janela. A alternativa — requestAnimationFrame — funciona na página comum e não funciona dentro de uma sessão imersiva, onde quem entrega o quadro é a sessão. O custo de adotá-lo agora seria reescrever o ambiente inteiro em torno do laço errado, descobrindo isso só no visor, com a página respondendo normalmente enquanto a cena congela — o que faz o diagnóstico apontar para todo lado menos para o laço.

1.6.5 O orçamento numa janela circular

O cenário é o ambiente rodando há alguns segundos: cento e vinte quadros na janela, custo médio abaixo de meio milissegundo e um pior intervalo bem acima da média, que é o engasgo que o corpo sente.

flowchart TD
  ini["registrar(custoMs, intervaloMs, chamadas, triangulos)"] --> grava["custos e intervalos no indice proximo"]
  grava --> circ["proximo = (proximo + 1) % custos.length"]
  circ --> ench{"preenchidos menor que custos.length"}
  ench -->|"sim"| inc["preenchidos += 1"]
  ench -->|"nao, janela cheia"| sobrescreve["o quadro mais antigo foi sobrescrito"]
  inc --> fimr["fim de registrar()"]
  sobrescreve --> fimr
  ler["ler()"] --> vazio{"preenchidos === 0"}
  vazio -->|"sim"| zeros["devolve leitura zerada"]
  vazio -->|"nao"| varre{"para i de 0 ate preenchidos"}
  varre -->|"intervalo maior que pior"| pior["pior = intervalo"]
  varre -->|"intervalo maior que tetoMs"| acima["acima += 1"]
  pior --> varre
  acima --> varre
  varre -->|"fim"| med["medias de custo e intervalo"]
  med --> fiml["devolve LeituraDoOrcamento"]

sequenceDiagram
  autonumber
  participant laco as core/laco.ts
  participant orc as core/orcamento.ts
  participant demo as 03_demo.ts
  participant pai as ui/painel.ts
  laco->>orc: registrar(0.42, 16.6, 12, 4380)
  Note over orc: janela circular de 120 quadros; o 121o sobrescreve o 1o
  demo->>orc: ler()
  orc-->>demo: 120 quadros, custo medio 0.44 ms, pior intervalo 21.3 ms
  demo->>orc: linhasDoOrcamento(leitura)
  orc-->>demo: 5 linhas de texto
  demo->>pai: atualizar(linhas, decorrido)

A janela circular de tamanho fixo, e o que ela custou. A alternativa era acumular tudo e tirar a média do percurso inteiro. O custo é que a média do ambiente carregado dilui o engasgo de um segundo atrás até ele desaparecer do número — e o engasgo é justamente o que se está caçando. O buffer fixo esquece de propósito.

Custo e intervalo medidos separados. O custo é o tempo do nosso trabalho na CPU, que podemos cortar; o intervalo é o tempo entre duas imagens entregues, onde entram a GPU, o compositor do sistema e tudo o mais que divide o aparelho conosco. Custo baixo com intervalo alto é gargalo fora do nosso código, e um número só não distingue os dois casos.

1.6.6 A reparentagem, e o desvio que a torna conferível

O cenário tem um clique. A página do marco abre, a cena aparece com o eixo girando, e alguém aperta o botão Prender a engrenagem ao eixo. Na execução descrita aqui, a única coisa que mudou na árvore foi o recuo de uma linha: engrenagem-grande, que aparecia sob tampo junto das outras quatro peças, passou a aparecer sob eixo. A engrenagem não se moveu um milímetro na tela, e a partir daquele instante ela gira junto com o eixo sem que nenhuma linha copie a rotação dele.

flowchart TD
  ini["reparentar(filho, novoPai)"] --> up1["filho.updateWorldMatrix(true, false)"]
  up1 --> up2["novoPai.updateWorldMatrix(true, false)"]
  up2 --> antes["posicaoAntes lida de filho.matrixWorld"]
  antes --> alg["matrizLocal = inversa de novoPai.matrixWorld vezes filho.matrixWorld"]
  alg --> troca["novoPai.add(filho) — so agora o parentesco muda"]
  troca --> dec["matrizLocal.decompose(position, quaternion, scale)"]
  dec --> up3["filho.updateWorldMatrix(true, false)"]
  up3 --> depois["posicaoDepois lida de filho.matrixWorld"]
  depois --> fim["devolve posicaoAntes.distanceTo(posicaoDepois)"]

A ordem dos três últimos passos é o conteúdo do módulo, e ela não é livre. Trocar de pai antes de calcular a matriz local produziria a matriz errada; calcular depois de decompor sobrescreveria o resultado. O caminho tem uma única ordem correta, e nenhuma das ordens erradas acusa nada no ato — a peça simplesmente aparece em outro lugar, e quem está aprendendo culpa a biblioteca.

sequenceDiagram
  autonumber
  participant demo as 03_demo.ts
  participant hier as core/hierarquia.ts
  participant eng as engrenagem-grande
  participant eixo as eixo
  Note over demo,eixo: clique em Prender a engrenagem ao eixo
  demo->>hier: reparentar(engrenagem, cena.eixo)
  hier->>eng: updateWorldMatrix(true, false)
  hier->>eixo: updateWorldMatrix(true, false)
  hier->>eng: le matrixWorld antes da troca
  eng-->>hier: posicao de mundo original
  hier->>eixo: add(engrenagem)
  hier->>eng: decompose escreve position, quaternion e scale
  hier->>eng: updateWorldMatrix(true, false)
  eng-->>hier: posicao de mundo apos a troca
  hier-->>demo: 4.2e-17 m de desvio residual

O valor devolvido é o que torna a operação conferível: 4.2e-17 metros nesta execução, que é a distância entre onde a engrenagem estava antes da troca e onde ela ficou depois. O número muda a cada clique, e vale saber por quê antes que alguém na turma pergunte: o eixo está girando, então a matriz de mundo do novo pai é outra a cada quadro, e a conta parte de valores diferentes. O que se confere é a ordem de grandeza. Zero não acontece em aritmética de ponto flutuante; o que se pede é que o resíduo fique no limite do arredondamento, e um desvio de dezessete casas decimais abaixo do metro fica. Desvio grande naquele mesmo campo não seria falha da álgebra: seria sinal de que alguma matriz do caminho até a raiz estava desatualizada quando a conta foi feita, e o lugar de procurar seria a chamada de atualização, não a fórmula.

A álgebra escrita à mão, e o que ela custou. A biblioteca oferece a operação pronta, numa chamada só, que faz exatamente a mesma coisa. A alternativa era usá-la. O custo de escrever as sete linhas é o tempo de escrevê-las e a chance de errar a ordem; o ganho é a medida do desvio, que a versão pronta não devolve. Sem esse número, a preservação do lugar no mundo é uma promessa que o estudante aceita porque a imagem não mudou — e “a imagem não mudou” é precisamente o que ele não consegue distinguir de “a imagem mudou por menos de um pixel”.

1.6.7 A árvore descrita, e a pilha que o percurso constrói

O cenário é a mesma abertura da página: descreverArvore(cena.sala) roda uma vez e devolve quinze linhas, e percorrer() se chama a si mesmo a cada nível.

flowchart TD
  ini["descreverArvore(raiz, profundidadeMaxima 4)"] --> chama["percorrer(sala, 0)"]
  chama --> corte{"nivel maior que profundidadeMaxima"}
  corte -->|"sim"| volta["retorna sem escrever"]
  corte -->|"nao"| nome{"no.name vazio"}
  nome -->|"sim"| anon["nome = sem nome mais no.type"]
  nome -->|"nao"| proprio["nome = no.name"]
  anon --> push["linhas.push(recuo de dois espacos por nivel mais nome)"]
  proprio --> push
  push --> filhos{"para cada filho de no.children"}
  filhos -->|"percorrer(filho, nivel + 1)"| corte
  filhos -->|"sem mais filhos"| volta
  volta --> fim["devolve linhas"]

sequenceDiagram
  autonumber
  participant demo as 03_demo.ts
  participant hier as core/hierarquia.ts
  participant est as relatorio/estrutura.ts
  demo->>hier: descreverArvore(cena.sala)
  hier->>hier: percorrer(sala, 0)
  hier->>hier: percorrer(bancada, 1)
  hier->>hier: percorrer(tampo, 2)
  hier->>hier: percorrer(engrenagem-grande, 3)
  hier-->>demo: 15 linhas, recuo por nivel
  demo->>est: montarEstruturaDaCena(raizEstrutura, arvore, frasesDaOrdem)
  est-->>demo: bloco pre e paragrafos escritos na pagina

No instante em que percorrer() alcança engrenagem-grande, quatro quadros estão empilhados, e o de baixo é o quarto nível da árvore:

flowchart TD
  q0["descreverArvore(sala, 4) — linhas vazio"]
  q0 -->|"percorrer(sala, 0)"| q1["percorrer — no sala, nivel 0"]
  q1 -->|"percorrer(bancada, 1)"| q2["percorrer — no bancada, nivel 1"]
  q2 -->|"percorrer(tampo, 2)"| q3["percorrer — no tampo, nivel 2"]
  q3 -->|"percorrer(engrenagem-grande, 3)"| q4["percorrer — no engrenagem-grande, nivel 3 — CORRENTE"]

Vale comparar em aula com a estrutura da cena: aqui a profundidade da pilha acompanha a profundidade dos dados, um quadro por nível, porque o percurso é recursivo sobre a própria árvore. É o contraste com a reparentagem, cujo caminho é linear e não empilha nada por mais fundo que o nó esteja. Profundidade de dados e profundidade de execução coincidem quando o algoritmo caminha sobre a estrutura — e só então.

O corte em profundidadeMaxima, e o que ele custou. A alternativa era descrever a árvore inteira. O custo é o instrumento: quinze linhas se lêem de relance na projeção, e uma árvore de biblioteca completa traz dezenas de nós internos que a biblioteca cria sozinha. O que se ganharia em completude se perderia em legibilidade, e o instrumento existe para ser lido.

1.6.8 O painel dentro da cena

O cenário são dois momentos seguidos: um quadro em que o texto do orçamento não mudou, e outro em que o pior intervalo subiu. No primeiro, atualizar() retorna sem redesenhar; no segundo, ele repinta a textura.

flowchart TD
  ini["atualizar(linhas, decorrido)"] --> junta["texto = linhas.join"]
  junta --> freio{"decorrido menos ultimoDesenho menor que INTERVALO_DE_REDESENHO ou texto igual a ultimoTexto"}
  freio -->|"sim"| sai["retorna sem redesenhar"]
  freio -->|"nao"| marca["ultimoDesenho e ultimoTexto atualizados"]
  marca --> des["desenhar(linhas)"]
  des --> fundo["pincel pinta o fundo e o titulo"]
  fundo --> quebra{"para cada linha"}
  quebra -->|"quebrar(linha, larguraUtil)"| medir{"measureText(linha).width menor ou igual a larguraUtil"}
  medir -->|"sim"| inteira["a linha cabe inteira"]
  medir -->|"nao"| palavras["parte por palavra ate caber"]
  inteira --> quebra
  palavras --> quebra
  quebra -->|"fim"| escreve{"linhaY maior que ALTURA_PX menos MARGEM_PX"}
  escreve -->|"sim"| corta["para de escrever e conta em cortadas"]
  escreve -->|"nao"| fill["fillText e linhaY += ENTRELINHA_PX"]
  fill --> escreve
  corta --> tex["textura.needsUpdate = true"]

sequenceDiagram
  autonumber
  participant demo as 03_demo.ts
  participant pai as ui/painel.ts
  participant cena as core/cena.ts
  demo->>pai: montarPainel("Custo do quadro")
  pai-->>demo: Painel com no, atualizar e encarar
  demo->>cena: cena.suporteDoPainel.add(painel.no)
  Note over demo,pai: o painel e objeto do mundo, preso ao tampo, e nao ao canto da tela
  loop a cada quadro
    demo->>pai: atualizar(linhasDoOrcamento, decorrido)
    pai-->>demo: retorna sem redesenhar enquanto o texto nao muda
  end
  demo->>pai: atualizar(linhas com pior intervalo novo, decorrido)
  pai->>pai: desenhar(linhas)
  pai->>pai: quebrar("Chamadas de desenho: 12 · triangulos: 4380", larguraUtil)
  pai-->>demo: textura marcada para reenvio

O painel preso ao tampo, e o que isso custou. A alternativa era desenhar as linhas do orçamento sobre a imagem, no canto da tela, como qualquer contador de quadros faz. O custo é que num visor não existe canto de tela: o retângulo acompanharia a cabeça, ficaria a poucos centímetros dos olhos e cobraria em desconforto. Preso à bancada, ele vira objeto do mundo, e lê-se virando a cabeça para ele — o que também é o comportamento que o estudante precisa experimentar antes de projetar qualquer interface para dentro do ambiente.

A quebra de linha medida com a régua do contexto, e não por contagem de caracteres. A alternativa é contar letras. O custo é que a fonte é proporcional: iiii e MMMM têm o mesmo número de letras e larguras que diferem em três vezes, então o painel cortaria cedo demais numas linhas e transbordaria em outras — e o instrumento que mede o orçamento passaria a mentir sobre o próprio texto.

1.6.9 A ordem das operações, medida

O cenário é o do encaixe: um mesmo ponto, um deslocamento e um giro de noventa graus em torno do eixo vertical, aplicados nas duas ordens possíveis.

flowchart TD
  ini["compararOrdem(ponto, deslocamento, eixo, anguloEmGraus)"] --> rot["rotacao = makeRotationFromQuaternion"]
  rot --> tra["translacao = makeTranslation(dx, dy, dz)"]
  tra --> tr["girandoPrimeiro = ponto aplicado a T vezes R"]
  tr --> rt["transladandoPrimeiro = ponto aplicado a R vezes T"]
  rt --> dist["distancia = girandoPrimeiro.distanceTo(transladandoPrimeiro)"]
  dist --> fim["devolve ComparacaoDeOrdem"]
  tr -.->|"a direita toca o ponto primeiro"| nota["a leitura da linha engana: le-se da direita para a esquerda"]

sequenceDiagram
  autonumber
  participant chama as app/oficina.ts
  participant tr as core/transformacao.ts
  chama->>tr: compararOrdem(ponto, deslocamento, eixo Y, 90 graus)
  tr->>tr: girandoPrimeiro — gira em torno da origem do pai e so entao desloca
  tr->>tr: transladandoPrimeiro — desloca e gira, descrevendo um arco
  tr->>tr: distancia entre os dois destinos
  tr-->>chama: ComparacaoDeOrdem com distancia diferente de zero
  chama->>tr: emMetros(girandoPrimeiro)
  tr-->>chama: texto com tres casas decimais

A demonstração como código executável, e não como parágrafo. A alternativa era afirmar em prosa que a ordem importa. O custo é que inverter duas operações produz uma cena que continua desenhando, sem erro algum, com os objetos nos lugares errados — o defeito mais caro de diagnosticar olhando o resultado, porque não há mensagem: há uma peça a meio metro de onde deveria estar e trinta linhas plausíveis para inspecionar. O número que compararOrdem() devolve encerra a discussão sem apelo à intuição.

1.7 Verificação do estado deste módulo

O que precisa estar verdadeiro antes de o módulo seguinte começar.

Verificação Como se confere
A cena é uma árvore, e o parentesco tem razão de projeto ler a estrutura impressa e justificar cada nível
Mover a bancada leva junto tudo o que está sobre ela deslocar o nó da bancada e conferir que nada fica para trás
A reparentagem preserva a posição no mundo prender e soltar, lendo o desvio a cada vez
O eixo gira na mesma velocidade em máquinas diferentes cronometrar uma volta em duas máquinas
A suspensão da aba não produz salto trocar de aba, voltar, e observar o mecanismo
O custo do quadro é legível de dentro da cena ler o painel sem recorrer à página
O ambiente se ajusta ao redimensionar e ao girar o aparelho redimensionar a janela e girar o celular
O código analisa limpo em modo estrito verificação de tipos do projeto, sem emissão

A terceira linha é a que decide o módulo seguinte. Peça que pula um milímetro aqui pula um palmo diante da turma, e o módulo de manipulação inteiro se apoia nessa operação.

O que fica pronto ao fim deste módulo é uma estrutura, e não uma paisagem. A oficina é feia, tem cinco peças e um painel de texto. Ela também tem uma árvore que justifica cada parentesco, um relógio que não mente sobre o tempo e um teto declarado antes do primeiro polígono caro. A partir do módulo seguinte entra conteúdo — e ele entra dentro de um envelope que já existe.