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"]
1 Grafo de cena e laço de renderização
A estrutura que sustenta todo o resto é também a única que não aparece na imagem que ela produz.
O que fica de pé ao fim destas páginas. Por que uma cena bem montada e uma mal montada produzem a mesma fotografia. A ordem em que escala, rotação e translação se compõem, e o que acontece quando duas delas trocam de lugar. Como um objeto muda de dono sem sair do lugar. E por que o custo de cada quadro precisa estar visível antes de existir conteúdo pesado para culpar.
Duas pessoas montam a mesma oficina. Sobre a bancada há um suporte com encaixes, cinco peças soltas no tampo e um painel virado para quem trabalha. As duas terminam no mesmo dia, e as duas telas mostram exatamente a mesma imagem.
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, ajustados a olho até a peça parecer bem posta. A segunda escreveu uma árvore: o tampo sustenta um suporte, e as peças penduram do tampo. Nenhuma delas sabe onde fica o canto da sala.
Agora alguém arrasta a bancada meio metro para a direita. Na segunda tela a oficina inteira acompanha. Na primeira, a bancada vai sozinha e deixa para trás cinco peças, um suporte e um painel, flutuando no ar exatamente onde estavam. Nenhum erro foi impresso. Nenhuma exceção subiu. As duas montagens continuam sendo programas corretos, que desenham com precisão aquilo que lhes foi mandado desenhar.
Repare no que essa comparação precisou para existir: um movimento. Enquanto ninguém arrasta a bancada, a estrutura errada é fotogênica. Ela desenha bem, roda bem e sobrevive a qualquer demonstração de dois minutos. A conta chega adiante, no dia em que a engrenagem aparecer meio centímetro atrás do eixo a que deveria estar presa.
1.1 Guardar a relação, e não a coordenada
Coordenada é um fato sobre agora. Relação é um fato sobre sempre.
Diante das duas telas idênticas, o que autoriza dizer que as montagens são diferentes? A tentação é concluir que são equivalentes, e elas não são. Uma guardou coordenadas; a outra guardou relações.
Imagina a cena por dentro. A montagem por lista funciona porque alguém fez as contas uma vez: a peça está a trinta centímetros do eixo, o eixo está sobre o suporte, o suporte está sobre o tampo (que está a noventa centímetros do chão). Somando tudo, chega-se ao número que a peça recebeu. O número está certo, e foi conferido. 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.
1.1.1 O que o nó guarda, e o que ele ignora
Uma árvore de nós é uma estrutura de dados banal. O que interessa nela é o que se decide não guardar. Falta justamente o que pareceria mais útil: onde o objeto está.
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.
O menor exemplo concreto é a peça sobre o tampo. Ela guarda que está oito centímetros acima do tampo e vinte à direita do centro dele, e isso é tudo o que ela sabe. Para descobrir onde ela está na sala, alguém percorre o caminho até a raiz compondo cada transformação encontrada. A resposta só existe no instante em que a conta é feita, porque ninguém a guardou em lugar nenhum.
Daí sai uma consequência que merece ser dita com 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 simplesmente não tem onde acontecer.
Na prática a biblioteca percorre a árvore uma vez por quadro, de cima para baixo, multiplicando a transformação do pai pela local de cada nó. O valor calculado fica guardado até o próximo quadro, e é ele que o desenho consome. Isso explica um comportamento que assusta quem mexe na cena fora do laço: alterar a posição de um objeto e ler imediatamente a posição dele no mundo devolve o valor antigo. O valor não está errado — está atrasado. A diferença entre as duas coisas separa um defeito de um mal-entendido sobre quando as contas são feitas.
A definição deliberadamente não exige outras coisas. Ela não pede que a árvore seja rasa, nem que os níveis tenham significado físico, nem que um nó tenha geometria. Um nó vazio, sem forma alguma, é legítimo e frequentemente útil: ele existe para dar aos filhos um sistema de coordenadas comum. É a razão mais honesta de um objeto invisível existir numa cena.
1.1.2 Por que este objeto é filho daquele
Decidido que a cena é uma árvore, sobra o que a estrutura não responde sozinha: quem é filho de quem. A resposta fácil é deixar o código decidir por acidente — o objeto criado dentro de um bloco vira filho do que estava ali. Funciona no dia em que se escreve, e é a origem da hierarquia sem justificativa.
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 é sobre proximidade visual, nem sobre ordem de criação, nem sobre os dois terem sido escritos no mesmo arquivo. É sobre a relação do domínio: o suporte está apoiado no tampo, logo arrastar a bancada tem de levar o suporte.
o parentesco justificado, em src/bancada/core/cena.ts
Quatro linhas e dois comentários, e o comentário é a parte que não se deduz do resto. As quatro linhas 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.
Existe um teste rápido — nenhuma ferramenta, meio minuto de trabalho — para hierarquia montada por acaso. 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. Uma engrenagem filha de outra engrenagem, só porque as duas nasceram no mesmo bloco, é o caso típico.
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 sequer.
Uma última escolha de montagem parece detalhe e não é: onde cada peça nasce. A tentação é criar cada uma já dentro do lugar a que ela pertence no final, porque a cena inicial fica arrumada desde o primeiro segundo.
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, com cada peça no lugar em que uma peça de verdade estaria antes de alguém montar coisa alguma: em cima da mesa, esperando. Nascer no destino apagaria a operação que dá sentido à montagem inteira. 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. Qual é ele? Objetos que nunca se separam do pai não têm troca alguma a demonstrar: a perna de uma mesa, o mostrador de um instrumento. 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. Duas operações, dois números, e dois destinos diferentes conforme a ordem em que elas são aplicadas.
Vamos por partes. Tome uma peça na origem. Aplicando a rotação primeiro, ela gira em torno de si mesma — está na origem, então girar não a tira do lugar — e depois se desloca trinta centímetros. Chega ao destino, girada, sem surpresa nenhuma. E se a ordem for outra? A peça vai primeiro para trinta centímetros de distância. Agora a rotação acontece, e ela não gira mais em torno de si: gira em torno da origem, que ficou trinta centímetros atrás. Ela descreve um arco e para num ponto onde não havia razão alguma para ela 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
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, sem alterar nada do que foi feito antes.
A escala traz a armadilha mais discreta das três. Escalar um objeto pela metade depois de tê-lo transladado divide também a translação: ele para na metade da distância pretendida, encolhido e no lugar errado ao mesmo tempo. Duas dessas operações comutam entre si. Quais? Escala uniforme e rotação podem trocar 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, e é dela que vêm os objetos enviesados que ninguém consegue explicar. Translação não comuta com nada.
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. A expressão 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 exatamente o contrário 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. As bibliotecas seguem o que a álgebra já fixava.
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));Não há ali nenhuma abstração inteligente, e a simplicidade serve a um propósito: o trecho é executável e conferível. Imprimir a distância entre os dois pontos encerra a discussão em um segundo, e encerra melhor do que qualquer argumento sobre intuição geométrica. Note também 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 que torna este assunto caro é o comportamento do programa quando a conta está na sequência errada. Ele não para. Não imprime nada. Não deixa rastro. Compare com um erro comum de programação. Um índice fora do intervalo produz exceção, com linha, arquivo e pilha de chamadas. Um campo ausente estoura em algum lugar identificável. As duas coisas doem, e as duas se resolvem seguindo o rastro. Uma transformação composta fora de ordem produz uma cena inteira, desenhada com correção impecável, em que um objeto está num lugar diferente do previsto.
Existe um agravante que vem da própria competência de quem escreve. Alguém que conhece bem a cena olha para a peça deslocada e encontra uma 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 essas explicações são reais e acontecem — e é por isso que a explicação errada sobrevive: ela 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.
A defesa não é atenção redobrada, que é o conselho que todo mundo dá e ninguém consegue seguir. É ter, em algum lugar do código, um trecho executável que compare dois caminhos e imprima a distância entre eles. Atenção é um recurso que acaba; 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. Acontece que o primeiro impulso é procurar o defeito, e não há: aquele giro à distância é a prova de que a operação funcionou. Entender por quê exige separar duas coisas que a conversa cotidiana empilha.
Olha só o que acontece no mundo físico. Prender uma coisa a outra envolve dois movimentos ao mesmo tempo: a peça é levada até o encaixe, e ali passa a acompanhar o que a segura. Os dois acontecem tão juntos que a linguagem não os separa. Num grafo de cena eles são operações distintas, e uma é bem mais fundamental que a outra. Trocar de pai altera de quem o objeto depende. Levá-lo até o 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"]
Se a peça troca de pai e simplesmente 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. A operação correta recalcula os números de modo que a posição no mundo continue idêntica.
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}}
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 é exatamente isso que a inversa da matriz do pai 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.
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 vai concluir que a função está quebrada e reescrever 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.
Uma operação que promete não mover nada faz uma promessa que se verifica em números. Meio milímetro de salto não se vê numa peça de dez centímetros. Meio milímetro de salto numa peça que alguém está segurando é um solavanco.
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, que é a distância entre onde a peça estava antes e onde ela ficou depois. Idealmente esse valor seria zero, e ele não é. Nesta cena fica entre zero e três décimos de quatrilionésimo de metro — o limite do ponto flutuante de 64 bits, e nada mais. Ruído dessa ordem (que é o próprio chão da aritmética de máquina) não interessa a ninguém. O que interessa é o valor que não é dessa ordem. Um desvio de milímetros significa que a conta usou uma matriz vencida, e a matriz vencida estava em algum ancestral do caminho até a raiz.
É por isso que as duas primeiras linhas do corpo da função atualizam esse caminho antes de qualquer coisa. Sem elas, a conta é feita com a matriz do quadro anterior, e a peça vai parar onde ela estava, não onde está. Por que devolver o desvio, se ele é sempre ruído? Porque o ruído é o resultado quando tudo está certo, e o valor da medida está justamente em distinguir esse caso do outro. 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 produzida é idêntica, pixel por pixel, no primeiro dia. Quem faz isso e vê funcionar tem razão em resistir a mudar, e o argumento de que 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. A primeira, quando o eixo passar a girar: acompanhar deixa de ser somar posições e vira compor rotações. A peça que só soma fica meio centímetro atrás. A segunda, quando algo acima do eixo se mover, porque a soma foi escrita para um nível de profundidade e o mundo tem vários. A terceira, quando a peça precisar ser solta — e não houver operação inversa, porque nunca houve operação. A quarta é a mais irritante. A cópia acontece dentro do laço, e basta alguém inserir uma tarefa antes dela: a peça passa a copiar a posição do quadro anterior. Ela acompanha o eixo com um quadro de atraso, invisível quando o eixo está parado e 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 não desenha uma vez. Ele desenha de novo, indefinidamente, dezenas de vezes por segundo, e cada repetição é uma chamada que alguém precisou pedir. Quem faz esse pedido decide se o mesmo código funciona dentro de um visor ou apenas na janela do navegador.
1.4.1 A fonte de quadros e a sequência dentro deles
A forma tradicional de animar uma página é pedir ao navegador que chame uma função antes do próximo repintar da janela. É um mecanismo bom, antigo, e sincroniza a animação com a atualização da tela. A maior parte do que se escreve para a web usa exatamente isso.
Veja bem: dentro de uma sessão imersiva esse mecanismo não é chamado. 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. Quem entrega os quadros ali é a sessão, por um mecanismo distinto. O que torna isso caro é a forma da falha: a página continua respondendo, os botões funcionam, o console não acusa nada, e a cena dentro do visor está congelada na primeira imagem. Um diagnóstico que comece pelos suspeitos habituais vai passar por permissões, por geometria e por material antes de chegar ao laço.
A tentação natural é escrever a bifurcação à mão, com um teste que pergunta se há sessão ativa e chama um mecanismo ou outro. Funciona, e envelhece mal. Pior que a repetição do teste é o momento da 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 aponta o diagnóstico para o botão. A biblioteca oferece uma saída limpa, e ela consiste em não escolher: existe um laço que troca de fonte sozinho, e quem escreve o código de cima nunca é informado de qual das duas está ativa.
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, que 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
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 o tamanho da área pode ter mudado desde o quadro anterior, e desenhar num alvo com dimensões erradas distorce a imagem inteira. Conferir isso a cada quadro parece desperdício. Na prática é a alternativa barata a rastrear todos os eventos que poderiam ter mudado 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 ela precisa ficar no fim. O contador de chamadas de desenho é zerado no início de cada renderização, então lê-lo depois de desenhar entrega o número do quadro que acabou de sair. Lê-lo antes entregaria o número do quadro anterior, com uma diferença que ninguém nota e que estraga todo diagnóstico feito com base nele.
A inversão mais tentadora dessa sequência é desenhar antes de avançar a cena, com uma justificativa aparentemente boa: desenhar cedo entrega a imagem mais rápido. O código funciona e a animação acontece. O que muda é o conteúdo de cada imagem — o que sai na tela é o estado do quadro anterior, e a animação inteira fica atrasada em exatamente um quadro. No computador de mesa isso é uma fração pequena de segundo. Num visor, o atraso soma à latência que o aparelho já tem entre o movimento da cabeça e a imagem correspondente, e esse orçamento não tem folga para um quadro doado por engano. A inversão simétrica é pior: medir o tempo depois de avançar a cena faz cada passo usar a medida do quadro anterior, e o movimento sai correto em média e errado em cada instante.
1.4.2 O relógio contra a contagem de quadros
A forma mais simples de animar é somar um valor fixo a cada quadro. O eixo gira um grau por quadro, e pronto. Repara que essa frase esconde uma dependência. É pouco código, é fácil de ler, e funciona perfeitamente na máquina de quem escreveu. Na máquina ao lado, o mesmo eixo gira mais devagar: aquela máquina entrega menos quadros por segundo, e como cada quadro vale um grau, menos quadros significam menos graus. O mecanismo tem duas velocidades, e a velocidade depende do aparelho, não do código.
Onde essa falha aparece é a parte cruel. Não aparece para quem escreveu, porque a máquina de quem escreve é quase sempre a mais rápida disponível. Ela aparece no aparelho de outra pessoa, que é exatamente onde ninguém está olhando, e chega como um relato vago de que está mais lento ali.
A correção muda o sentido do número. Em vez de somar um valor por quadro, mede-se quanto tempo passou desde o quadro anterior e avança-se proporcionalmente. O eixo passa a girar noventa graus por segundo, e essa segunda frase é verdadeira em qualquer máquina. A conversão é menos automática do que parece: cada constante muda de unidade, e quem multiplica o valor antigo pelo tempo sem trocar a unidade obtém um mecanismo sessenta vezes mais lento e conclui que o método é ruim. Há um segundo hábito a trocar, mais insidioso. Contagens de quadro aparecem em lugares que não parecem animação — um efeito que dura trinta quadros, um atraso de dez quadros, uma transição que interpola em cem passos. Todos carregam a mesma dependência de cadência.
O ganho vem com uma contrapartida que merece nome. O movimento deixa de ser determinístico, porque duas máquinas medem intervalos ligeiramente diferentes e produzem sequências ligeiramente diferentes de posições intermediárias. Para animação isso é irrelevante. Para simulação que precise ser reproduzível, é o começo de outra conversa, com passo fixo e acumulador.
E o primeiro quadro? Ele não tem anterior a partir do qual medir, e a pergunta tem duas respostas plausíveis e uma correta. A tentadora é inventar um número razoável — se a cadência esperada é de sessenta imagens por segundo, o primeiro intervalo poderia ser aquele valor, e a animação começaria já andando. Parece inofensivo, e é a origem de um solavanco justamente na primeira imagem que alguém vê com o aparelho no rosto. A resposta correta é zero: o primeiro quadro não tem intervalo a medir, então a cena não avança e aquele quadro mostra o estado inicial, que é o estado que alguém escreveu de propósito.
Agora imagine que alguém troca de aba para responder uma mensagem e volta oito segundos depois. O navegador deixou de pedir quadros nesse período, e isso é comportamento desejado. O intervalo medido no primeiro quadro de volta é de oito segundos, e ele está correto: esse tempo passou mesmo. Avançar a cena por ele é 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 — o intervalo aplicado, o tempo acumulado, o intervalo real medido e um indicador de descarte. As duas últimas não servem para animar nada. Servem para que alguém, olhando o diário depois, saiba que aquele salto foi visto e recusado. Um relógio que apenas limitasse o salto em silêncio resolveria o problema visual e apagaria a informação.
Escolher o valor do teto é uma 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. Um valor da ordem de alguns décimos de segundo cobre 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 distingue entre eles, e não precisa distinguir. No visor, aliás, o teto deixa de ser gentileza. Um mecanismo que dá 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 é outra coisa — é 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 do envelope — ao contrário de quase toda metáfora de desempenho — é literal. 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.
1.5.1 O teto, e de onde vêm as suas duas metades
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. Esse é todo o tempo disponível entre uma imagem e a seguinte. O trabalho que não terminar a tempo não é entregue com atraso — a apresentação acontece no instante marcado, com o que estiver pronto, e o que estiver pronto é a imagem anterior, que aparece pela segunda vez.
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 para ser desenhada 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. Olha só o que isso faz com a ideia de otimizar. 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 última frase da definição é a mais densa, e contradiz o instinto. Perceba que 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
Duas constantes, cada uma com a documentação ao lado dizendo de onde o número saiu. Um 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 entre eles — média entre tetos é um valor que não corresponde a aparelho nenhum, e um valor assim não pode ser conferido 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 (cinco milésimos a mais do que o visor tolera), e um ambiente construído dentro desse teto passa em todos os testes na janela e estoura no visor. Ali, estourar produz mal-estar físico, e não uma animação menos lisa.
Orçar pelo visor custa aparência. Sobram cinco milésimos por quadro na janela, e o ambiente fica mais modesto do que o computador aguentaria. No fim das contas, 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: ele se ajusta retirando coisas que já estavam prontas. Há ainda uma terceira posição defensável. Um ambiente que nunca vai a visor não tem razão para respeitar o teto mais apertado, e adotá-lo ali seria disciplina sem propósito.
1.5.2 O que o painel responde, e o que ele não responde
Um teto declarado e nunca conferido é uma frase num arquivo. Para que ele trave alguma coisa, é preciso comparar cada quadro contra ele enquanto o ambiente é construído, e essa comparação exige duas grandezas que se parecem e não são a mesma.
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"]
Um quadro com custo de três milissegundos e intervalo de trinta ilustra a diferença. O programa fez a parte dele em um décimo do tempo disponível, e a imagem mesmo assim demorou dez vezes mais. Otimizar o programa ali é trabalhar numa parte que já estava barata, com ganho máximo de três milissegundos num problema de trinta.
Medidas por quadro são ruidosas. Um quadro isolado pode ter custo alto por razões que não dizem nada sobre o ambiente — 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
A janela é circular e curta: as amostras recentes substituem as antigas num arranjo de tamanho fixo, e o que aconteceu há um minuto não tem mais peso sobre o número exibido. A alternativa seria acumular a média desde o início da execução — mais simples de escrever, e produzindo um número bem mais estável. Pois é justamente o estável que atrapalha. Um engasgo de um segundo, diluído em cinco minutos de execução, move a média em uma fração desprezível — ele some do número exatamente enquanto o corpo de quem estava usando o ambiente o registrou por inteiro. A proporção de quadros acima do teto fecha essa lacuna, porque é contagem de violações dentro da janela e um único quadro estourado aparece nela na hora.
O tamanho da janela tem consequência direta. 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 justamente o que se queria ver. Duas contagens acompanham as medidas de tempo, e elas não são de tempo: o número de chamadas de desenho e o número de triângulos descrevem o trabalho pedido, e não o tempo gasto. Quando o custo sobe e essas duas ficam paradas, o trabalho pedido 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. Elas 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 consome tempo de execução: 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 terceiro estado da Bancada
A oficina aparece pela primeira vez, e a imagem dela não denuncia nenhuma das três coisas que foram construídas.
Dois estados atrás, a Bancada — a oficina de montagem servida em três regimes pela mesma URL — declarava o que esperava dos aparelhos. No estado seguinte, perguntava a eles o que de fato ofereciam. Nenhum dos dois desenhava coisa alguma.
Agora há oficina. Um tampo a noventa centímetros do chão, um suporte com encaixes apoiado nele, cinco peças soltas sobre a superfície e um painel virado para quem trabalha. A cena é modesta de propósito: caixas e cilindros cinzentos, sem material elaborado, sem textura, sem nada que desvie a atenção do arranjo.
a composição do marco, em src/demos/03_demo.ts
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);
A composição apresenta cinco objetos uns aos outros e não faz mais nada. O palco sabe desenhar e não sabe o que é uma bancada. A cena conhece a oficina e não sabe medir tempo. O relógio mede tempo e ignora que existam peças. O orçamento compara números com um teto e não sabe de onde os números vêm.
Essa ignorância mútua é o que permite que os módulos seguintes troquem um dos cinco sem tocar nos outros quatro. Quando o regime imersivo entrar, o palco muda e a cena não. Quando a entrada por ponteiro entrar, um passo novo é acrescentado à lista do laço, e nenhum dos cinco arquivos existentes é editado.
1.6.1 O botão que prende sem mover
A demonstração deste estado cabe em um botão e dois cliques. O primeiro prende a engrenagem grande ao eixo. A engrenagem não se move: 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, descrevendo um círculo amplo sobre a bancada.
Quem vê isso pela primeira vez procura o defeito, e não há defeito. A engrenagem passou a ser filha do eixo, e o eixo gira. Tudo o que é filho de um objeto que gira, gira com ele — inclusive o que está a trinta centímetros de distância. A órbita é a prova visual de que a troca de pai aconteceu e de que a posição no mundo foi preservada.
O segundo clique desfaz. A engrenagem volta a ser filha do tampo, de novo sem sair do lugar, e para de girar no ponto em que estava. É a mesma função chamada com outro pai, e ver isso reduz a operação ao que ela é: uma troca de a quem o objeto pertence, sem nenhuma opinião sobre onde ele deve ficar.
O diário registra as duas operações com o desvio ao lado. Prender e soltar produzem, cada um, um número que deveria ser zero e é ruído de arredondamento. Um valor acima de um milímetro ali significa que a peça pulou, e a demonstração vira um teste em vez de uma exibição.
1.6.2 O painel preso ao tampo
No estado anterior o relatório saía numa tabela comum da página, e a razão declarada era que ainda não havia mundo onde pendurá-lo. Agora há, e o painel muda de natureza junto com a cena.
Ele é um objeto da oficina, preso ao tampo e virado para quem trabalha, e não um retângulo sobreposto à imagem. A escolha não é estética. Dentro de um visor não existe canto da tela: existe um campo de visão que acompanha a cabeça, e texto grudado nele fica a poucos centímetros dos olhos, sempre presente, impossível de ignorar e desconfortável em minutos.
Preso ao tampo, o painel se comporta como qualquer outro objeto do mundo. Quem quer lê-lo olha para ele; quem não quer, desvia o olhar. Aproximar-se aumenta o texto, afastar-se diminui, e a leitura acontece à distância de trabalho — que é a mesma em que estão as peças que se está montando.
O painel também herda o parentesco. Arrastar a bancada leva o painel junto, sem que uma linha tenha sido escrita para isso, exatamente como acontece com as peças e com o suporte. É a mesma árvore, e o painel não é exceção nela.
A atualização acontece algumas vezes por segundo, e não a cada quadro. Escrever texto numa textura e enviá-la à placa de vídeo é caro, e um medidor que consumisse parte relevante do envelope que está informando produziria um número sobre si mesmo. Algumas atualizações por segundo bastam para leitura humana, e a diferença de custo é grande.
O estado se verifica em cinco conferências, todas de menos de um minuto. A árvore se lê com um nível por recuo e cada parentesco se justifica. Deslocar o nó da bancada leva junto tudo o que está sobre ela. Prender e soltar a engrenagem devolvem desvio no ruído do ponto flutuante. O eixo completa uma volta no mesmo tempo em duas máquinas de cadências diferentes. E trocar de aba por alguns segundos não produz salto na volta.
1.7 O que não aparece na imagem
Uma lista de coisas que não acontecem é a pior apresentação de resultados que existe. É também 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.
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
- 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.
- 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 uma captura de tela nunca serviu de prova de nada aqui.
Há uma injustiça na natureza desse trabalho, sem consolo possível. 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: fundação mal feita acaba dando sinal, com trinca, desnível, porta que não fecha. Uma estrutura de cena mal feita não trinca. Ela 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. Na prática, as coisas construídas aqui são as únicas do percurso 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 (sem serem tocados), servindo a tudo o que vier por cima. A geometria crua desta cena nunca foi provisória por falta de tempo — peças bonitas escondem estrutura, porque um objeto bem modelado no lugar errado continua parecendo bem modelado. Caixas cinzentas não distraem ninguém. O envelope declarado aqui existia sem nada para gastá-lo, e isso está prestes a mudar.
O que segue leva essa estrutura para dentro do seu próprio projeto.
Tarefa 1: Montar a cena como árvore de nós
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.
Tarefa 2: Reparentar sem recalcular à mão
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. Escrevê-la enquanto a cena ainda é pequena mantém o resultado conferível a olho.
Tarefa 3: Fazer o laço andar contra o relógio
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.