1 Grafo de cena e laço de renderização — Resumo

Versão de revisão. As cinco definições deste capítulo estão aqui inteiras, e as ressalvas junto — são elas que se erram quando se revisa às pressas. O que encolheu foi o número de exemplos. O tratamento longo está na versão completa deste capítulo e no capítulo correspondente do livro.

Duas pessoas montam a mesma oficina. Uma bancada, um suporte com encaixes, cinco peças soltas no tampo, um painel virado para quem trabalha. As duas terminam no mesmo dia, e as duas telas mostram exatamente a mesma imagem.

Guarde essa imagem. Ela volta no fim, com um detalhe a mais.

A primeira pessoa escreveu uma lista. Cada peça recebeu três números de posição e três de orientação, medidos a partir de um canto da sala e ajustados a olho. A segunda escreveu uma árvore: o tampo sustenta o suporte, as peças penduram do tampo, e nenhuma delas sabe onde fica o canto da sala. Então alguém arrasta a bancada meio metro para a direita. Numa tela a oficina inteira acompanha. Na outra, a bancada vai sozinha e deixa para trás cinco peças, um suporte e um painel, flutuando no ar. Nenhum erro impresso, nenhuma exceção. Os dois programas continuam corretos, e desenham com precisão o que lhes mandaram desenhar.

1.1 Guardar a relação, e não a coordenada

Coordenada é um fato sobre agora. Relação é um fato sobre sempre.

A lista funciona porque alguém fez a conta uma vez. Trinta centímetros até o eixo. O eixo sobre o suporte. O suporte sobre o tampo, a noventa do chão. Somado, dá o número que a peça recebeu. Repare no tempo verbal — ele está certo enquanto nada mudar acima dele. Quando o tampo sobe dois centímetros, todos os números abaixo ficam errados de uma vez, e nenhum deles sabe disso.

NotaDefinição — Nó de cena e transformação local

Uma cena é uma árvore de nós. Cada nó guarda uma transformação local — posição, orientação e escala — expressa em relação ao seu pai, e não em relação ao mundo.

A transformação de um nó no mundo é o produto das transformações locais de todos os nós no caminho dele até a raiz. Ela é calculada a cada consulta, nunca armazenada, e muda quando qualquer ancestral muda, sem que o nó seja tocado.

Numa árvore de nós, o que interessa é o que se decide não guardar. E falta o que pareceria mais útil: onde o objeto está. A peça sabe que está oito centímetros acima do tampo e vinte à direita do centro dele. É tudo. Para descobrir onde ela está na sala, alguém percorre o caminho até a raiz compondo cada transformação encontrada.

flowchart TD
    R["raiz da cena"]
    A["nó intermediário<br/>transformação local A"]
    B["nó folha<br/>transformação local B"]
    R --> A
    A --> B
    B -.->|"no mundo:<br/>A composta com B"| M["posição no mundo<br/>calculada, nunca guardada"]
    A -.->|"muda"| C["todos os descendentes<br/>mudam sem ser tocados"]
Figura 1: A transformação local é guardada; a do mundo é resultado, e muda quando um ancestral muda.

Daí sai a consequência que merece todas as letras: não existe, na árvore, nenhum lugar onde a posição no mundo esteja escrita errado. Ela não pode estar desatualizada porque não está guardada, e o defeito clássico de dois valores que deveriam concordar não tem onde acontecer. Guarde também o que a definição não exige. Ela não pede árvore rasa, nem níveis com significado físico, nem que um nó tenha geometria. Um nó vazio é legítimo e útil: existe para dar aos filhos um sistema de coordenadas comum.

Uma ressalva de tempo acompanha isso, e é a que mais assusta. A biblioteca percorre a árvore uma vez por quadro. Multiplica a transformação do pai pela local de cada nó, e o valor calculado dura até o quadro seguinte. Alterar a posição de um objeto fora do laço e ler logo em seguida a posição dele no mundo devolve o valor antigo. Ele não está errado — está atrasado, e a diferença separa um defeito de um mal-entendido sobre quando as contas são feitas.

Sobra o que a estrutura não responde sozinha: quem é filho de quem. O critério defensável cabe numa frase. Um nó é filho de outro quando mover o segundo tem de mover o primeiro junto. Não é proximidade visual, nem ordem de criação, nem os dois terem nascido no mesmo bloco de código. É a relação do domínio.

o parentesco justificado, em src/bancada/core/cena.ts
// 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);

O comentário é a parte que não se deduz do resto. As quatro linhas — repare — diriam a mesma coisa se o parentesco fosse arbitrário. O que distingue uma decisão de um acidente é o registro do motivo, e esse registro não sobrevive na memória de ninguém. Para hierarquia montada por acaso há um teste de meio minuto — imprima a árvore com um nível por recuo e leia em voz alta. Onde a leitura produzir uma frase que não faz sentido no mundo, há um parentesco a corrigir.

Antes de seguir. Pegue a cena que você tem em mente para o seu projeto e escolha dois objetos quaisquer. Se alguém arrastasse o primeiro meio metro, o segundo teria de ir junto? A resposta é o parentesco, e ela não depende de uma linha de código.

Uma última escolha de montagem parece detalhe e não é: onde cada peça nasce.

as peças nascem sobre o tampo, em src/bancada/core/cena.ts
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);
}

As peças nascem soltas sobre o tampo, e não dentro do suporte. A cena inicial fica desarrumada de propósito, e uma cena que já começa montada não tem o que ensinar sobre montar. O critério é mais estreito do que parece, e o caso oposto existe. Objetos que nunca se separam do pai (a perna de uma mesa, o mostrador de um instrumento) não têm troca alguma a demonstrar. Criá-los já no lugar é o mais simples e o mais honesto. Nasce solto aquilo que a interação vai mover.

1.2 A conta certa na sequência errada

Trinta centímetros e um quarto de volta, em duas ordens, dão dois lugares.

Tome uma peça na origem. Girando primeiro, ela roda em torno de si mesma — está na origem, e girar não a tira do lugar — e depois se desloca trinta centímetros. Chega ao destino, girada, sem surpresa. E se a ordem for outra? A peça vai primeiro para trinta centímetros de distância. A rotação já não a gira em torno de si. Gira em torno da origem, que ficou lá atrás. Ela descreve um arco. E para num ponto onde não havia razão alguma para estar.

flowchart LR
    P["peça na origem<br/>duas instruções: 30 cm e um quarto de volta"]
    P --> A["gira primeiro,<br/>desloca depois"]
    P --> B["desloca primeiro,<br/>gira depois"]
    A --> DA["destino previsto:<br/>30 cm do centro, girada"]
    B --> DB["ponto sobre um arco:<br/>mais de 40 cm do previsto"]
    DA --> S["a cena desenha os dois casos<br/>sem erro, aviso ou mensagem"]
    DB --> S
Figura 2: Os mesmos dois números, as mesmas duas operações, e dois pontos distintos conforme a sequência.

A ordem canônica é escala, depois rotação, depois translação, e cada posição tem motivo. Escalar primeiro dimensiona o objeto enquanto ele ainda está na origem, onde escalar não desloca. Girar em segundo o orienta em torno do próprio centro, pela mesma razão. Transladar por último leva um objeto já dimensionado e já orientado até onde ele deve ficar.

A escala traz a armadilha mais discreta das três. Encolher pela metade depois de transladar divide também a translação. A peça para na metade da distância pretendida — menor e no lugar errado ao mesmo tempo. Duas dessas operações comutam entre si. Quais? Escala uniforme e rotação trocam de lugar sem mudar o resultado, porque encolher igualmente em todas as direções não muda ângulo nenhum. Escala não uniforme não comuta com rotação. É dela que vêm os objetos enviesados que ninguém consegue explicar. Translação não comuta com nada.

NotaDefinição — Composição de transformações

A transformação local de um nó é a composição de três operações, sempre na mesma ordem: escala, depois rotação, depois translação.

A composição se escreve como produto de matrizes e se lê da direita para a esquerda: em T \cdot R, quem atua primeiro sobre o ponto é R. A ordem não é convenção de escrita — trocar duas operações de lugar produz um ponto diferente, e a cena continua sendo desenhada sem erro, aviso ou mensagem.

Essa leitura ao contrário engana quem tem os olhos treinados para outra coisa. A expressão T \cdot R gira primeiro e translada depois, apesar de o T estar escrito antes. Já R \cdot T translada primeiro e faz a peça descrever o arco. Quem lê a linha em português, da esquerda para a direita, entende o contrário exato do que ela faz. A convenção não é invenção de biblioteca: o tratamento canônico dessas composições está em Real-time rendering, de Akenine-Möller e colegas, cuja quarta edição é de 2018.

os dois destinos, em src/bancada/core/transformacao.ts
// 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));

A simplicidade do trecho serve a um propósito. Ele é executável e conferível, e imprimir a distância entre os dois pontos encerra a discussão em um segundo. Note o que a definição não afirma. Ela não diz que a ordem escala-rotação-translação seja a única possível, nem que toda biblioteca a siga. Diz que, fixada uma ordem, trocar duas operações produz outro ponto — e essa parte não depende de biblioteca, porque é álgebra.

O caro, aqui, é o comportamento do programa quando a conta está na sequência errada. Ele não para. Não imprime nada. Um índice fora do intervalo produz exceção com linha, arquivo e pilha; um campo ausente estoura em lugar identificável. As duas coisas doem e as duas se resolvem seguindo o rastro. Uma transformação fora de ordem produz uma cena inteira, desenhada com correção impecável, com um objeto onde ninguém o previu.

Existe um agravante que vem da própria competência de quem escreve. Quem conhece bem a cena olha a peça deslocada e encontra explicação plausível: o modelo veio com a origem em outro lugar, a peça foi exportada com outra escala, o arquivo de geometria tem outra convenção. Todas são reais e acontecem — e é por isso que a explicação errada sobrevive, porque tem concorrentes verossímeis.

De que tamanho é o estrago

Uma peça a trinta centímetros do centro, girada um quarto de volta na ordem trocada, para a mais de quarenta centímetros do lugar previsto. Quase metade de um braço estendido. Se a peça for pequena e a cena for grande, o desvio é indistinguível de uma escolha de projeto que alguém fez e ninguém questionou. E a defesa não é atenção redobrada, que é o conselho que todo mundo dá e ninguém consegue seguir: atenção é um recurso que acaba, e uma conta impressa continua ali na terça-feira seguinte.

Uma última armadilha dessa família vem da escala. Escala não uniforme aplicada num pai deforma tudo o que está abaixo dele, e deforma de um jeito que depende da orientação dos filhos. Um objeto girado dentro de um pai achatado sai enviesado, com um ângulo que não estava em lugar nenhum do código. É a razão de a escala não uniforme ser evitada em nós que tenham descendência.

1.3 Posse não é posição

A peça troca de dono sem andar um milímetro.

A peça está deitada no tampo, a trinta centímetros do eixo. Alguém aperta um botão para prendê-la ao eixo. Ela não se move. Fica exatamente onde estava — e então começa a girar em torno do eixo, a distância, como se estivesse presa a ele por uma haste invisível.

Que defeito produz uma peça que orbita o objeto ao qual acabou de ser presa? Nenhum — aquele giro à distância é a prova de que a operação funcionou. Entender por quê exige separar duas coisas que a conversa cotidiana empilha. Prender, no mundo físico, envolve dois movimentos ao mesmo tempo. A peça é levada até o encaixe, e ali passa a acompanhar o que a segura. Num grafo de cena são operações distintas. Trocar de pai altera de quem o objeto depende; levá-lo ao encaixe altera onde ele está. Nada obriga as duas a acontecerem juntas.

flowchart TB
    N["peça filha do tampo<br/>guarda 30 cm até o centro do tampo"]
    N --> Q{"ao trocar de pai,<br/>os números são recalculados?"}
    Q -->|"não: mantém o local antigo"| SALTO["os 30 cm passam a ser lidos<br/>a partir do eixo: a peça salta"]
    Q -->|"sim: local recebe a inversa do pai<br/>composta com o mundo"| IGUAL["mesma posição no mundo<br/>desvio no ruído do ponto flutuante"]
    IGUAL --> ORB["o eixo gira, e a peça gira com ele<br/>a 30 cm de distância"]
Figura 3: O que a troca de pai precisa recalcular para que a posição no mundo não mude.

Se a peça troca de pai e mantém os números que tinha, ela salta. Aqueles números eram distâncias até o pai antigo, e passam a ser lidos como distâncias até o pai novo. Sendo M_{\text{mundo}} a transformação do nó no mundo e M_{\text{pai}} a do novo pai, a transformação local que preserva o mundo é

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

NotaDefinição — Reparentagem que preserva o mundo

Reparentar um nó é trocar o pai dele mantendo inalterada a transformação que ele tem no mundo. A transformação local que preserva o mundo é a do novo pai, invertida, composta com a que o nó tem no mundo.

A operação troca a quem o nó pertence, e não onde ele está. Levar o nó até outro lugar é outra operação, e confundir as duas é confundir posse com posição.

A conta pergunta que coordenadas, no sistema do novo pai, apontam para o mesmo lugar do mundo — e é isso que a inversa faz. Nada na fórmula fala em encaixar, assentar ou alinhar. Um objeto pode ser reparentado a um pai do outro lado da sala e continuar do lado em que estava, orbitando à distância. Feio, correto, e revelador do que a operação faz.

AvisoReparentar não é encaixar

Um objeto reparentado continua onde estava. Um objeto encaixado foi levado até o destino e assentado lá. São duas operações, e a segunda ainda não foi escrita.

Quem espera que reparentar mova a peça conclui que a função está quebrada. E reescreve a montagem inteira dentro dela. O sintoma disso é uma função de reparentagem que cresce, ganha parâmetros de destino e deixa de servir a qualquer outro uso.

a reparentagem que devolve o próprio erro, em src/bancada/core/hierarquia.ts
/**
 * 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);
}

Repare no que a função devolve: um número, a distância entre onde a peça estava antes e onde ficou depois. Idealmente seria zero, e não é — nesta cena fica entre zero e três décimos de quatrilionésimo de metro, que é o limite do ponto flutuante de 64 bits e nada mais. Ruído dessa ordem não interessa a ninguém. Interessa o valor que não é dessa ordem. Um desvio de milímetros significa que a conta usou uma matriz vencida, guardada em algum ancestral do caminho até a raiz. É por isso que as duas primeiras linhas do corpo atualizam esse caminho antes de tudo. Uma função que devolve sempre zero não informa nada; uma que devolve o erro real informa toda vez que o erro deixa de ser real.

Existe uma alternativa à reparentagem que funciona, e ser honesto sobre isso vale mais do que fingir o contrário. Basta, a cada quadro, ler a posição do eixo e escrever na peça aquela posição somada à distância entre os dois. A imagem é idêntica, pixel por pixel, no primeiro dia. Quem faz isso e vê funcionar tem razão em resistir a mudar, e “existe uma forma mais elegante” não convence ninguém com prazo. Um argumento melhor precisa nomear o que exatamente vai quebrar.

Quebram quatro coisas, em datas diferentes. Quando o eixo girar, acompanhar deixa de ser somar posições e vira compor rotações, e a peça que só soma fica meio centímetro atrás. Quando algo acima do eixo se mover, porque a soma foi escrita para um nível de profundidade e o mundo tem vários. Quando a peça precisar ser solta, porque não há operação inversa de uma operação que nunca existiu. E a mais irritante: a cópia acontece dentro do laço, então uma tarefa inserida antes dela faz a peça copiar a posição do quadro anterior — invisível com o eixo parado, visível como tremor quando ele acelera.

1.4 Quem pede o próximo quadro, e com que relógio

Duas máquinas rodando o mesmo código, e o mesmo eixo dando uma volta em tempos diferentes.

Um ambiente tridimensional desenha de novo dezenas de vezes por segundo, e cada repetição é uma chamada que alguém precisou pedir. A forma tradicional de animar uma página é pedir ao navegador que chame uma função antes do próximo repintar da janela. Veja bem: dentro de uma sessão imersiva esse mecanismo não é chamado, porque o visor tem cadência própria, duas imagens a produzir em vez de uma, e um ciclo de apresentação que não é o da janela. E a falha é da pior espécie. A página responde, os botões funcionam, o console não acusa nada. A cena dentro do visor está congelada na primeira imagem.

A tentação é escrever a bifurcação à mão, com um teste que pergunta se há sessão ativa. Funciona, e envelhece mal. Pior que a repetição do teste é a transição: entrar e sair de sessão são eventos, e um laço que escolhe a fonte uma vez, no início, continua pedindo quadros à janela depois que a sessão abriu. O ambiente funciona antes do botão e congela depois dele, o que manda o diagnóstico para o botão. A saída limpa consiste em não escolher.

iniciar e parar, em src/bancada/core/laco.ts
iniciar(): void {
  palco.renderer.setAnimationLoop(quadro);
},
parar(): void {
  palco.renderer.setAnimationLoop(null);
  relogio.reiniciar();
},

Sete linhas para começar e parar, e uma delas não é óbvia: parar reinicia o relógio. Um laço interrompido e retomado sem isso mede, no primeiro quadro de volta, o tempo inteiro em que esteve parado — que pode ser meia hora.

Dentro de cada quadro há uma sequência, e ela não é livre. Quatro coisas acontecem: o alvo de desenho é ajustado, o tempo desde o quadro anterior é medido, a cena avança por esse tempo, e então a cena é desenhada.

flowchart LR
    Q["quadro pedido<br/>pela fonte ativa"] --> P["ajustar o alvo<br/>de desenho"]
    P --> T["medir o tempo<br/>desde o quadro anterior"]
    T --> A["avançar a cena<br/>por esse tempo"]
    A --> D["desenhar"]
    D --> M["medir o custo<br/>do que acabou de sair"]
    M -.->|"próximo quadro"| Q
Figura 4: A sequência dentro de um quadro. Medir o custo é a única etapa que pode ficar por último.
o corpo do quadro, em src/bancada/core/laco.ts
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,
  );
}

O ajuste do alvo vem primeiro porque a área pode ter mudado de tamanho, e desenhar num alvo de dimensões erradas distorce a imagem inteira. Conferir a cada quadro parece desperdício. É a alternativa barata a rastrear todo evento capaz de mudar o tamanho (inclusive a saída de sessão imersiva, que não dispara evento de janela nenhum). A medição de custo é a única etapa que pode ficar no fim, e precisa ficar. O contador de chamadas de desenho zera no início de cada renderização; lê-lo depois de desenhar entrega o número do quadro que acabou de sair.

As duas inversões dessa sequência saem caro. Desenhar antes de avançar a cena entrega a imagem mais cedo e põe na tela o estado do quadro anterior, atrasando a animação em exatamente um quadro. No computador de mesa é uma fração pequena de segundo; num visor soma à latência que o aparelho já tem entre o movimento da cabeça e a imagem, e esse orçamento não tem folga para um quadro doado por engano. A simétrica é pior: medir o tempo depois de avançar faz cada passo usar a medida anterior, e o movimento sai correto em média e errado em cada instante.

Falta o relógio. A forma mais simples de animar é somar um valor fixo por quadro — o eixo gira um grau por quadro, e pronto. Repara que essa frase esconde uma dependência: na máquina ao lado o mesmo eixo gira mais devagar, porque ela entrega menos quadros e cada quadro vale um grau. A velocidade passa a depender do aparelho, não do código. E a falha não aparece para quem escreveu, cuja máquina é quase sempre a mais rápida disponível. Aparece no aparelho de outra pessoa. Pois é: chega como um relato vago de que está mais lento ali.

A correção mede quanto tempo passou desde o quadro anterior e avança proporcionalmente. O eixo gira noventa graus por segundo, e essa segunda frase é verdadeira em qualquer máquina. Duas armadilhas moram na conversão. Cada constante muda de unidade, e quem multiplica o valor antigo pelo tempo sem trocá-la obtém um mecanismo sessenta vezes mais lento e conclui que o método é ruim. E contagens de quadro se escondem onde não parece haver animação — um efeito que dura trinta quadros, uma transição que interpola em cem passos —, carregando a mesma dependência de cadência.

Três ressalvas fecham o relógio. O movimento deixa de ser determinístico, porque duas máquinas medem intervalos ligeiramente diferentes: irrelevante para animação, começo de outra conversa para simulação reproduzível, com passo fixo e acumulador. O primeiro quadro não tem anterior a partir do qual medir. Inventar ali um número razoável (sessenta imagens por segundo, digamos) produz um solavanco na primeira imagem que alguém vê com o aparelho no rosto. A resposta correta é zero: o quadro mostra o estado inicial, que alguém escreveu de propósito. E quem troca de aba por oito segundos volta com um intervalo de oito segundos, medido corretamente, cujo uso é aritmeticamente impecável e visualmente desastroso.

o teto de salto, em src/bancada/core/relogio.ts
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 };

Cinco linhas, e a decisão está na segunda. Quando o intervalo real excede um teto, ele é substituído pelo teto: a cena avança pouco, o resto do tempo é descartado, e ficar atrasada em relação ao relógio de parede é o resultado desejado. A função devolve quatro coisas — intervalo aplicado, tempo acumulado, intervalo real medido e um indicador de descarte. As duas últimas não animam nada. Servem para que alguém, olhando o diário depois, saiba que aquele salto foi visto e recusado; um relógio que limitasse o salto em silêncio resolveria o problema visual e apagaria a informação.

Escolher o valor do teto é decisão de faixa larga com bordas ruins. Baixo demais, e a cena engasga em máquinas lentas que teriam entregue o quadro se lhes tivessem dado tempo. Alto demais, e o salto volta a incomodar. Alguns décimos de segundo cobrem a suspensão de aba sem transformar cada quadro lento em descarte. E a aba não é o único caso. O menu do sistema aberto sobre a sessão, a máquina que hiberna, o depurador parado num ponto de interrupção: todos produzem o mesmo intervalo enorme na volta. O relógio não precisa distinguir entre eles. No visor, aliás, o teto deixa de ser gentileza. Meia volta de uma vez numa janela é feio; um objeto que salta meio metro no campo de visão de quem está com o aparelho no rosto é um solavanco físico.

1.5 Onze milissegundos, e o instrumento que os mostra

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

A imagem é literal, e é aí que ela difere de quase toda metáfora de desempenho. Um envelope tem tamanho fixo, e o que não cabe fica de fora: não sai depois, não sai menor, não sai atrasado. De onde saiu o número? De uma conta de uma linha — um visor apresenta noventa imagens por segundo, e um segundo dividido por noventa dá pouco mais de onze milésimos.

NotaDefinição — Envelope do quadro

O envelope do quadro é o tempo disponível entre duas imagens consecutivas, dado pela cadência de apresentação do aparelho.

O envelope é um limite de admissão, e nenhuma quantidade de esforço amplia o que o aparelho já fixou. O trabalho que não couber nele não sai atrasado — ele não sai, e a imagem anterior é apresentada de novo.

O envelope tem duas metades de origens diferentes: o tempo disponível vem do aparelho de maior cadência previsto, e o trabalho que cabe nesse tempo vem do aparelho de menor capacidade prevista.

Uma cena que leva quinze milissegundos num aparelho de onze não roda a dois terços da velocidade. Ela entrega uma imagem nova a cada duas apresentações, porque não existe meia imagem. A degradação é em degraus, não em rampa, e os degraus são grandes. Num sistema comum, cortar vinte por cento do tempo é vinte por cento melhor. Sob envelope, cortar tempo não muda nada enquanto o quadro couber — e muda tudo no instante em que ele passa a caber.

A frase mais densa da definição é a última, e ela contradiz o instinto. O teto não vem de um aparelho: vem de dois.

Metade De onde vem Por quê
O tempo disponível por quadro do aparelho de maior cadência previsto é ele que apresenta com mais frequência, logo é ele que dá o intervalo mais curto
O trabalho que cabe nesse tempo do aparelho de menor capacidade prevista é ele que faz menos em cada milissegundo, logo é ele que dita quanto cabe

E se as duas metades vierem do mesmo aparelho? Uma delas fica subestimada, e a direção do erro depende de qual foi escolhido. Pelo mais rápido, sobra tempo e falta capacidade. Pelo mais lento, sobra capacidade e falta tempo. Nos dois casos o número calculado é maior que o real, e um teto folgado é um teto que não trava nada.

os dois tetos declarados, em src/bancada/core/orcamento.ts
/** 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;

Duas constantes, cada uma com a procedência do número documentada ao lado. Teto sem procedência escrita vira folclore em duas semanas. Alguém pergunta por que dezesseis e não vinte, ninguém lembra, e o valor passa a ser defendido por antiguidade. O código declara os dois, e não uma média — média entre tetos não corresponde a aparelho nenhum, e não pode ser conferida contra nada.

Aqui entra um juízo que é meu, e prefiro assiná-lo. Desenvolvendo no computador de mesa, o teto a respeitar é o do visor. Orçar pelo computador dá dezesseis milissegundos e meio de folga aparente, e o ambiente construído dentro dele passa em todos os testes na janela e estoura no visor, onde estourar produz mal-estar físico. Orçar pelo visor custa aparência: sobram cinco milésimos por quadro na janela. Trocar aparência por mal-estar é fácil, e só fica difícil quando o teto apertado é adotado tarde, porque aí ele não se ajusta com um parâmetro — ajusta-se retirando coisas prontas. Há uma terceira posição defensável: um ambiente que nunca vai a visor não tem razão para respeitar o teto mais apertado.

Um teto declarado e nunca conferido é uma frase num arquivo. Compará-lo com cada quadro exige duas grandezas que — olha só — se parecem e não são a mesma.

NotaDefinição — Custo e intervalo de um quadro

O custo de um quadro é o tempo consumido pelo trabalho do próprio programa entre uma imagem e a seguinte. É o que se escreve, e é o que se pode cortar.

O intervalo é o tempo real decorrido entre duas imagens apresentadas. Nele entra, além do custo, tudo aquilo com que o programa divide o aparelho.

São grandezas distintas, e nenhuma das duas estima a outra. Custo baixo com intervalo alto é sintoma de gargalo fora do programa, e diagnosticá-lo pelo custo sozinho leva a otimizar a metade que já estava barata.

flowchart TB
    subgraph INT["intervalo: tempo real entre duas imagens"]
        CUS["custo: o trabalho do próprio programa"]
        OUT["o que o aparelho faz para os outros:<br/>abas, compositor, driver, visor"]
    end
    INT --> T{"coube no envelope do aparelho?"}
    T -->|"sim"| NOVA["imagem nova apresentada"]
    T -->|"não"| REPETE["a imagem anterior aparece de novo<br/>e o conteúdo do quadro se perde"]
    CUS --> D["custo baixo com intervalo alto:<br/>o gargalo está fora do programa"]
Figura 5: As duas grandezas, o que cabe em cada uma e o que acontece quando o quadro não cabe.

Custo de três milissegundos com intervalo de trinta ilustra a diferença. O programa fez a parte dele em um décimo do tempo disponível, e a imagem demorou dez vezes mais. Otimizar ali rende, no máximo, três milissegundos num problema de trinta. Medidas por quadro são ruidosas, ainda por cima: um quadro isolado pode custar caro por uma coleta de lixo, um pico de outro processo, uma memória de vídeo ainda fria. Alguma agregação é necessária, e o modo de agregar decide o que o instrumento enxerga.

a janela circular, em src/bancada/core/orcamento.ts
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;

A janela é circular e curta: amostras recentes substituem as antigas num arranjo de tamanho fixo, e o que aconteceu há um minuto não pesa mais sobre o número exibido. A alternativa seria acumular a média desde o início da execução, mais simples e bem mais estável. Pois é o estável que atrapalha. Um engasgo de um segundo, diluído em cinco minutos, move a média numa fração desprezível. Ele some do número exatamente enquanto o corpo de quem usava o ambiente o registrou por inteiro. A proporção de quadros acima do teto fecha a lacuna, porque conta violações dentro da janela e um único quadro estourado aparece nela na hora.

O tamanho da janela decide o resto. Poucas dezenas de amostras cobrem menos de um segundo, e o número dança a cada leitura. Algumas centenas suavizam a dança e começam a diluir o engasgo isolado — que era o que se queria ver. Duas contagens acompanham as medidas de tempo e não são de tempo: chamadas de desenho e triângulos descrevem o trabalho pedido, não o tempo gasto. Quando o custo sobe e essas duas ficam paradas, o trabalho não mudou — mudou o preço de fazê-lo, e a causa está fora da cena.

Antes de seguir. O painel diz se o quadro coube e quando ele deixou de caber. Ele não diz onde o tempo foi gasto. Que ferramenta você usaria para responder à terceira pergunta, e por que ela não pode estar dentro da cena?

Localizar o gasto exige as ferramentas de perfilamento do navegador, que instrumentam a execução e atribuem tempo a funções. São boas, e rodam na janela, com teclado e tela grande — o que significa que não estão disponíveis para quem está com o aparelho no rosto. Um instrumento que respondesse às três perguntas dentro da cena teria de instrumentar a execução inteira, e instrumentar custa tempo: o quadro passaria a estourar por causa da medição. Pelo mesmo motivo o painel não é redesenhado a cada quadro — escrever texto numa textura e enviá-la à placa de vídeo está entre as operações mais caras de um quadro, e algumas atualizações por segundo bastam para leitura humana.

1.6 O que não aparece na imagem

Uma lista de coisas que não acontecem é a pior apresentação de resultados que existe, e é o resultado real.

Volte às duas telas idênticas do começo. Agora as duas montagens têm nome. Uma guardou coordenadas absolutas, calculadas uma vez e válidas até o primeiro movimento de um ancestral. A outra guardou transformações locais, que continuam válidas depois de qualquer movimento porque não descrevem o mundo — descrevem uma relação. A oficina de referência, a Bancada, é a segunda, e neste ponto do percurso ela ainda é feita de caixas e cilindros cinzentos.

flowchart LR
    E["a estrutura, invisível na imagem"]
    E --> C1["imprimir a árvore<br/>um nível por recuo"]
    E --> C2["mover um ancestral<br/>e ver quem acompanha"]
    E --> C3["prender e soltar,<br/>lendo o desvio"]
    E --> C4["mesmo ciclo, duas máquinas,<br/>cronômetro na mão"]
    E --> C5["ler o custo do quadro<br/>com o teto declarado ao lado"]
    C1 --> P["cena parada responde"]
    C3 --> P
    C5 --> P
    C2 --> M["só o movimento responde"]
    C4 --> M
Figura 6: As cinco conferências do estado, e quais delas só respondem com a cena em movimento.
ImportanteO saldo, em cinco conferências

A árvore se lê com um nível por recuo, e cada parentesco tem justificativa de domínio. Mover um ancestral leva junto todos os descendentes, sem código de acompanhamento. A reparentagem devolve um desvio, e o desvio fica no ruído do ponto flutuante.

O mesmo mecanismo leva o mesmo tempo para completar um ciclo em máquinas de cadências diferentes. E o quadro tem o seu tempo legível de dentro da cena, com o teto declarado ao lado.

Qual delas é adiada com mais frequência? A terceira, que também é a mais barata: prender, ler o desvio, soltar, ler de novo. Um milímetro é o limiar — abaixo disso é aritmética, acima disso é um salto que alguém vai sentir na mão adiante. Duas das cinco têm em comum uma propriedade útil: nenhuma se faz olhando a cena parada. Uma exige mover um ancestral e observar quem acompanha; a outra exige duas máquinas e um cronômetro. Estrutura só se confere em movimento. É por isso que uma captura de tela nunca serviu de prova de nada aqui.

Há uma injustiça na natureza desse trabalho. Ninguém olha uma árvore de cena bem montada e se impressiona: ela produz exatamente a mesma imagem que a lista de coordenadas produzia, e a lista foi escrita em metade do tempo. A comparação com a fundação de um prédio é boa pela metade, porque fundação mal feita acaba dando sinal — trinca, desnível, porta que não fecha. Uma estrutura de cena mal feita não trinca. Continua produzindo imagens perfeitas até o dia em que alguém pede uma coisa que ela não sabe fazer.

Sobra um consolo pequeno e verdadeiro. O que se constrói aqui é o que ninguém vai precisar refazer. Conteúdo é trocado, materiais são refeitos, regimes são acrescentados, e a árvore, o relógio e o teto continuam onde estão, servindo a tudo o que vier por cima. Peças bonitas escondem estrutura — um objeto bem modelado no lugar errado continua parecendo bem modelado, e caixa cinzenta não distrai ninguém. E aquela bancada arrastada meio metro para a direita, lá do começo, é a demonstração inteira: numa tela a oficina acompanha, na outra ela se desfaz, e é a única pergunta que a fotografia não responde. O envelope declarado aqui existe ainda sem nada para gastá-lo, e isso está prestes a mudar.