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

Três decisões entram na cena hoje, nenhuma delas aparece na imagem, e as três cobram em capítulos que ainda não chegaram.

A cena que sai daqui é feia de propósito. Cinco caixas cinzentas sobre uma bancada, um suporte com encaixes e uma placa de texto pendurada no tampo. O que interessa não está em nada disso. Está na árvore que diz quem carrega quem, no relógio que faz o mundo andar pelo tempo transcorrido, e no teto de gasto que cada quadro tem. Leia com um projeto seu em mente e vá perguntando, a cada seção: como eu decidiria isto no meu ambiente?

1.1 Nenhuma das três aparece na imagem

Em 1978, um jogo de arcade acelerava sozinho conforme a tela esvaziava. O programador notou o efeito e achou melhor assim.

Em 1978, num escritório da Taito em Tóquio, Tomohiro Nishikado terminou um jogo com 55 alienígenas descendo em formação sobre um canhão. Boa parte do hardware ele montou à mão, porque o que havia à venda não dava conta. Ao testar, notou algo estranho. Conforme os alienígenas eram destruídos, os que sobravam desciam mais depressa.

A causa estava na máquina, e não em nenhuma curva de dificuldade. Cada alienígena era desenhado um a um, e a tela cheia custava mais tempo que a tela quase vazia. Menos coisa a desenhar, laço mais curto, formação mais rápida. Nishikado contou depois que percebeu o efeito e o manteve, por achar que melhorava o jogo.

À primeira vista, é um defeito que virou atração. Essa história para cedo demais. O estranho está noutro lugar: a velocidade do mundo estava amarrada ao custo de desenhá-lo, e ninguém escreveu uma linha para isso acontecer. O programa não tinha noção de tempo. Tinha noção de quadros, e as duas coisas coincidiam enquanto o quadro custasse sempre o mesmo.

Nishikado teve sorte, e a sorte tem endereço. Ele escrevia para uma máquina só, e todo mundo jogava aquilo no mesmo gabinete. Você vai escrever para três classes de aparelho ao mesmo tempo. Um computador de mesa com vídeo integrado, um visor a 90 imagens por segundo e um celular que ninguém escolheu. Nenhum dos três desenha na cadência dos outros.

E olha só o que piora o quadro. Nenhum dos três mantém a própria cadência o tempo todo. O celular reduz quando esquenta. O navegador reduz quando a aba perde o foco. O computador de mesa reduz quando outra janela pede a placa de vídeo. Contar quadros não amarra o mundo a um aparelho: amarra a um aparelho em boas condições.

1.1.1 As três decisões e a data em que cada uma cobra

O que aparece na tela ao fim daqui é modesto, e vai continuar feio por mais dois capítulos. O que se decide é outra coisa, e nenhuma das partes dela chega à imagem. Isso torna o assunto desconfortável de ensinar, porque tudo o que se resolve aqui é invisível no resultado.

A primeira decisão é a estrutura. A cena passa a ser uma árvore de objetos, cada um guardando a própria transformação em relação ao pai. Sai a lista de coisas com coordenadas de mundo. A segunda é o relógio: o laço avança a cena pelo tempo transcorrido, e não pelo número de quadros desenhados. A terceira é o teto, o orçamento de tempo que cada quadro tem para gastar.

flowchart LR
    E["Estrutura:<br/>arvore de nos com<br/>transformacao local"] --> CE["Cobra no capitulo<br/>de manipulacao"]
    R["Relogio:<br/>a cena avanca pelo<br/>tempo transcorrido"] --> CR["Cobra na primeira<br/>maquina de outra pessoa"]
    T["Teto:<br/>orcamento por quadro<br/>declarado antes"] --> CT["Cobra no dia em que<br/>entra a peca pesada"]
Figura 1: As três decisões invisíveis deste capítulo e o momento em que cada uma delas cobra a conta.

Aqui está o problema pedagógico que atravessa tudo, e é honesto pô-lo na mesa. Uma cena montada com coordenadas absolutas produz exatamente a mesma imagem que uma cena montada como árvore. Um laço que conta quadros anima igualzinho ao laço que lê o relógio, desde que a máquina de teste seja a sua. E um ambiente sem teto declarado roda com folga, porque não há nada caro nele.

Então por que decidir tudo isso agora, quando não há como conferir a vantagem? Justamente por isso. As três cobram tarde e cobram caro, e cada uma tem endereço certo. A estrutura cobra no capítulo de manipulação. O relógio cobra na primeira vez que alguém abre o ambiente noutra máquina. O teto cobra no dia em que entra a primeira peça pesada.

Pense no ambiente que você está construindo. Se um colega abrisse o seu projeto no aparelho dele agora, o que mudaria de comportamento? E, das coisas que mudariam, quantas você conseguiria demonstrar na sua própria máquina? Guarde a resposta: ela é o tamanho da sua dívida invisível.

Existe um conselho que circula em tutorial de gráficos e que eu acho ruim: primeiro faça funcionar, depois otimize. Ele acerta sobre otimização e erra sobre estrutura. Trocar um algoritmo lento por um rápido é uma mudança local. Trocar uma cena plana por uma cena hierárquica não é, porque o custo não é proporcional ao tamanho da mudança. Ele é proporcional a quanto código já se apoiou na decisão antiga.

E há um agravante que torna essa dívida pior que as outras. O grupo que a contraiu tem razão em resistir. O ambiente dele funciona, a demonstração roda, a peça encaixa. Pedir que ele desmonte aquilo com base no futuro é pedir fé, e fé é moeda cara de gastar com quem já entregou resultado. A conversa costuma terminar num acordo ruim, adiando a correção para depois da próxima entrega.

Por isso as três entram aqui, com cinco peças na cena e tudo conferível a olho. Não porque este seja o momento em que elas rendem. Porque é o último momento em que são baratas.

1.1.2 Da grandeza medida ao que ela move

O capítulo anterior terminou com um inventário e uma falta. Havia pose por quadro, espaço de referência, graus de liberdade inferidos com prudência e um sintoma mensurável de perda de rastreamento. Todas grandezas honestas, todas conferidas contra o aparelho, e nenhuma delas com coisa alguma a que se aplicar.

Uma pose só interessa quando existe uma câmera a posicionar com ela. Um mapa do ambiente só interessa quando há objeto a fixar nele. Faltava o outro lado da conta, e ele começa a existir agora.

Repare no encaixe entre as duas coisas, porque ele é mais estreito do que parece. A pose que o aparelho entrega é uma transformação; um nó de cena guarda uma transformação. Não há conversão, não há adaptador, não há formato a traduzir. A grandeza medida no capítulo anterior é do mesmo tipo que a estrutura montada neste, e é por isso que mover a raiz da árvore leva o mundo inteiro junto.

Uma árvore de objetos com transformação local é, então, exatamente aquilo que uma pose move. E move de uma vez só, sem que ninguém escreva a propagação.

A restrição nova é a que aquele capítulo anunciou na última frase, e ela muda de natureza. Até aqui o problema era saber onde as coisas estão. Daqui em diante, é terminar a conta a tempo.

1.2 Quem carrega quem cabe numa frase

Empurre uma mesa posta e o copo vai junto, sem que ninguém calcule a nova posição do copo.

O prato acompanha, o garfo acompanha, e você não pensou em nenhum dos dois. O copo está sobre a mesa, e essa relação faz o trabalho sozinha. Um ambiente virtual não tem essa cortesia de graça. Se cada objeto guardar a própria posição em relação ao mundo, arrastar a mesa move a mesa e mais nada. O prato fica boiando no ar, exatamente onde estava.

A saída é velha e é boa. Cada objeto guarda a posição dele em relação ao pai, e não em relação ao mundo. O prato está a doze centímetros da borda da mesa. Onde ele está na sala é assunto da mesa. Cada nó guarda três coisas próprias: onde está, como está girado, em que tamanho. A posição no mundo sai de multiplicar isso pela posição do pai, e a conta sobe a árvore até a raiz.

Grafo de cena, em uma frase e uma fórmula

Uma árvore enraizada cujos nós são objetos do ambiente. Cada nó guarda uma transformação local, escrita no sistema de coordenadas do pai, e nenhum deles guarda coordenada de mundo. A transformação de mundo de um nó é o produto acumulado de todas as transformações locais no caminho da raiz até ele. A raiz é o único nó cuja transformação local já é a de mundo.

M_n = L_{r_1} \cdot L_{r_2} \cdots L_{p(n)} \cdot L_n

O menor exemplo cabe em dois nós. A bancada está no chão da sala; o suporte está a 0,9 m do chão da bancada, que é a altura do tampo. Repare no que a definição não exige. Ela não pede profundidade fixa, não pede que os filhos sejam poucos, e não diz uma palavra sobre a ordem de desenho. Também não exige que a árvore corresponda a alguma coisa física. Nada impede que a lâmpada seja filha do parafuso, e o resultado é uma oficina em que apertar o parafuso apaga a luz.

1.2.1 A pergunta que revela hierarquia montada por acaso

Por que o suporte é filho do tampo, e não da sala? A pergunta parece retórica e não é. As duas montagens produzem a mesma imagem, e a diferença só aparece quando alguma coisa se move. O critério cabe numa frase: arrastar a bancada tem de levar junto tudo o que está sobre ela, sem que ninguém escreva uma linha para isso.

flowchart TD
    S["Sala<br/>(raiz: nao esta apoiada em nada)"] --> B["Bancada<br/>(apoiada no chao da sala)"]
    B --> SU["Suporte com encaixes<br/>(apoiado no tampo)"]
    B --> P["Pecas soltas<br/>(sobre o tampo)"]
    B --> PA["Painel de leitura<br/>(preso ao tampo)"]
    SU --> EN["Encaixes<br/>(fazem parte do suporte)"]
Figura 2: A árvore da oficina, com o motivo de cada parentesco escrito ao lado do nó.

Aplique a frase e a árvore se monta sozinha. O suporte está apoiado no tampo, logo é filho dele. As peças estão sobre o tampo, logo também. O painel está preso ao tampo, logo idem. A sala não está apoiada em coisa nenhuma, e por isso é a raiz. Note que o critério é do domínio, e não do código: ele pergunta o que acompanha o quê no mundo representado.

Existe um teste que custa segundos e que eu aplico em toda cena que me mostram. Pego um nó qualquer e pergunto por que aquele objeto é filho daquele outro. Resposta hesitante já é diagnóstico. Quem montou a hierarquia por razão de projeto responde na hora, com uma frase sobre o domínio. Quem a montou por acaso responde com uma frase sobre o editor de texto.

E quando duas relações competem? Uma chave de fenda apoiada no tampo, dentro de uma gaveta que também pende do tampo, poderia ser filha de qualquer um dos dois. A regra que uso é escolher o pai cuja movimentação a peça deve acompanhar. Se a gaveta abre e a chave vai junto, o pai é a gaveta. Quando nenhuma das duas se move, a escolha é indiferente hoje e deixa de ser assim que uma delas ganhar movimento.

Há uma conferência mecânica que dispensa a conversa toda, e ela custa seis linhas. O ambiente escreve a própria estrutura em texto, um nível por recuo, e você lê a árvore como leria um sumário. O que se procura ali é qualquer peça pendurada direto na raiz, porque é onde o acaso costuma deixá-la. Uma engrenagem filha da sala, em vez do tampo, é o sintoma clássico de um objeto criado antes de alguém decidir onde ele morava.

Eu já vi essa impressão mudar uma discussão de projeto em menos de um minuto. Um grupo defendia a hierarquia dele com convicção, imprimiu a árvore, e as duas peças soltas na raiz apareceram na segunda linha. Ninguém precisou argumentar. Uma estrutura impressa diz, em seis linhas, como o autor entendeu o domínio que está representando — e um arquivo com quarenta coordenadas de mundo não diz nada, porque as coordenadas são verdadeiras e mudas.

Às vezes a frase do domínio não decide sozinha. Um objeto pode estar apoiado em nada: uma partícula de poeira suspensa, um marcador flutuante, um efeito preso à câmera. A frase pergunta o que carrega o quê, e a resposta ali é “ninguém”. Nesses casos o nó pende da raiz por decisão, e não por descuido, e a diferença entre as duas coisas é uma linha de comentário no arquivo da cena. Escreva a linha. Ela é o que separa, seis meses depois, o filho da raiz que alguém escolheu do filho da raiz que alguém esqueceu.

Nada disso sai de graça, e apresentar a árvore só pelo lado bom seria vender. Ela cobra três coisas. A primeira é indireção: saber onde um objeto está no mundo deixa de ser uma leitura e vira uma conta que sobe a árvore inteira. A segunda é sincronia. Se a matriz de um ancestral mudou e as de baixo ainda não foram recalculadas, a conta devolve a posição do quadro anterior, sem erro e sem aviso.

A terceira cobrança é conceitual, e confunde quem chega da modelagem. Escala se propaga. Um nó com escala não uniforme distorce tudo o que pendura nele, e a distorção acompanha a rotação dos filhos de um jeito que raramente é o pretendido. É por isso que quase todo ambiente sério mantém escala uniforme nos nós estruturais e deixa a deformação para a geometria.

Vale a troca? Vale, e a razão cabe numa frase: sem hierarquia não existe a operação de trocar de pai, e o capítulo de manipulação inteiro se apoia nela. Prender, orientar, encaixar e soltar são quatro nomes para mexer no parentesco de um nó. Uma cena plana não tem parentesco a mexer, e quem a escolheu vai reimplementar as quatro operações à mão.

1.2.2 Onde Sutherland pôs o primeiro galho

A ideia é bem mais velha que qualquer visor citado neste livro. Em 1963, Ivan Sutherland defendeu no MIT uma tese orientada por Claude Shannon. O programa dela chamava-se Sketchpad, rodava no TX-2 do Lincoln Laboratory e se operava com uma caneta de luz apontada para a tela. Não havia visor, não havia rastreamento e não havia realidade virtual. Havia um sistema de desenho técnico.

O problema que ele resolvia era chato e concreto. Um esquema elétrico com quarenta resistores idênticos exigia desenhar o mesmo símbolo quarenta vezes, com a caneta, um a um. Pior que o trabalho inicial era a manutenção: mudar o símbolo obrigava a refazer as quarenta cópias, e uma delas sempre ficava para trás.

A saída foi separar o desenho mestre das suas instâncias. A instância não guardava o desenho. Guardava uma referência ao mestre e uma transformação própria — onde ficava, como estava girada, em que tamanho. Alterar o mestre alterava as quarenta de uma vez, porque nenhuma delas tinha cópia para alterar. Reconhece a estrutura? Referência mais transformação local é exatamente o que um nó de cena guarda.

O Sketchpad permitia ainda instâncias dentro de instâncias, e isso fecha o desenho. É composição de transformações locais aninhadas, com o resultado saindo do produto ao longo do caminho. Era a árvore de cena antes de a palavra existir. E a caneta de luz explica por que a ideia nasceu ali: cada traço custava minutos de uma máquina cara, então refazer quarenta símbolos idênticos tinha preço contábil imediato.

Desconfie de história de origem contada reta demais, inclusive desta. O Sketchpad não inventou hierarquia de transformações, que a geometria projetiva já tratava antes dele. O que aquele trabalho tem de raro é ter juntado as três coisas num sistema que funcionava — referência, transformação e aninhamento — e que outras pessoas puderam ver rodando. Daí sai uma postura de projeto que vale mais que a anedota: estrutura de dados que sobrevive sessenta anos costuma acertar alguma coisa sobre o problema.

1.3 Quarenta e dois centímetros e nenhum aviso

Duas linhas trocadas de lugar, quase meio metro de desvio. E nenhuma mensagem de erro em lugar nenhum.

Cada nó guarda translação, rotação e escala. Para desenhar, as três viram uma matriz só. E aí aparece uma pergunta que parece de contabilidade e decide o comportamento da cena inteira: em que ordem elas se combinam? A biblioteca responde sempre igual. Escala primeiro, rotação depois, translação por último, e o resultado multiplicado pela matriz do pai.

Essa ordem não é gosto de quem escreveu a biblioteca. Pense num objeto que você quer com o dobro do tamanho e posto a meio metro dali. Faça o teste na ordem errada: leve o objeto para longe, depois dobre. Dobrar o quê? A escala multiplica coordenadas a partir da origem, e o objeto já não está lá. Dobrar agora dobra também a distância, e ele termina a um metro.

Aplicar a escala com o objeto ainda na origem resolve isso. Ali, multiplicar por dois muda o tamanho e mais nada, porque não há distância a multiplicar junto. Depois vem a rotação, que também gira em torno da origem. Só quando as duas terminaram é que a translação leva o resultado para o lugar. Escala e rotação são operações ancoradas na origem; a translação é a que tira o objeto dela. Tudo que precisa da origem vem antes de perdê-la.

A armadilha de leitura, e ela não produz erro. A ordem em que as operações acontecem é o inverso da ordem em que aparecem escritas. Compor translação com rotação, nessa grafia, significa girar e depois transladar. Trocar os dois argumentos compila, executa, desenha e põe o objeto noutro lugar. Nenhuma ferramenta acusa, porque as duas expressões são igualmente válidas. O jeito de nunca mais errar é ler a expressão de dentro para fora, a partir do ponto: a matriz encostada no vetor atua primeiro, e a mais à esquerda entra por último.

1.3.1 O arco em torno de um ponto onde a peça não está

Chega de álgebra. O que acontece na tela quando a ordem inverte? O caso a examinar é o do encaixe, porque é o que a oficina vai fazer o percurso inteiro. Uma peça precisa ir até o lugar do encaixe e assumir a orientação dele. São duas operações: levar a peça a 30 cm dali e girá-la um quarto de volta.

Gire primeiro. A peça está na origem do pai e gira ali mesmo, sem sair do lugar. Depois é levada 30 cm adiante no eixo horizontal, e termina onde se queria, com o quarto de volta aplicado. Agora translade primeiro. A peça vai para 30 cm dali e então é girada. Só que a rotação gira em torno da origem do pai, e a peça já não está lá. Ela descreve um arco de 90 graus em torno de um ponto onde não está.

flowchart LR
    P0["Peca na origem do pai"] --> G["Girar primeiro"]
    P0 --> TT["Transladar primeiro"]
    G --> G2["depois levar 30 cm em x"]
    G2 --> D1["Destino (0,30; 0; 0)"]
    TT --> T2["depois girar um quarto de volta<br/>em torno da origem do pai"]
    T2 --> D2["Destino (0; 0; -0,30)"]
    D1 --> DIF["0,424 m entre os dois destinos<br/>e nenhuma mensagem de erro"]
    D2 --> DIF
Figura 3: As duas ordens possíveis para as mesmas duas operações, os dois destinos e a distância entre eles.

Duvida do tamanho do estrago? Os dois destinos ficam a 0{,}30\sqrt{2} um do outro, ou seja, 42 centímetros. Numa bancada de 1,4 m de largura, isso é a peça no meio da mesa contra a peça caindo da beirada. E o desvio é sempre a corda do arco, que cresce junto com o raio. Com meia volta em vez de um quarto, ele dobra e vira o diâmetro.

Veja bem o que torna esse erro caro, porque não é o tamanho dele. É a ausência de sintoma diagnóstico. Não há exceção, não há aviso do compilador, não há valor inválido, não há nada em vermelho. Existe uma cena que desenha normalmente, uma peça no lugar errado, e trinta linhas plausíveis a inspecionar. Todas parecem certas, porque todas estão certas — a errada é a ordem, e ordem não tem cara de erro.

E o parentesco piora o diagnóstico. O arco é em torno da origem do pai, então o mesmo código produz desvios diferentes conforme o nó esteja pendurado num lugar ou noutro. Mover o objeto na árvore muda o sintoma sem tocar na causa. Repare também no que não conserta: girar menos, transladar menos, ou ajustar a coordenada final à mão até a peça parecer certa. Esse último remendo é o mais frequente, porque funciona — para aquele ângulo e para aquela distância.

Qual é, então, o hábito que evita isso? Escrever a composição uma vez, num lugar só, e nunca mais compor à mão em outro ponto do código. Foi o que fiz na oficina. Existe um arquivo cuja única responsabilidade é combinar transformações, e ele carrega o comentário sobre a leitura da direita para a esquerda no próprio corpo. Comentário na prosa do livro ajuda quem está lendo o livro; comentário no arquivo ajuda quem está com o problema na tela às onze da noite.

Eu podia ter parado no parágrafo anterior. Achei pouco, porque argumento sobre ordem de transformação é o tipo de coisa que todo mundo concorda em tese e erra na prática. Então a oficina calcula. Uma função recebe um ponto, um deslocamento, um eixo e um ângulo, aplica as duas ordens ao mesmo ponto e devolve os dois destinos com a distância entre eles. Com os valores do encaixe, ela imprime 0,424 m.

Por que 0,424, e não meio metro? Porque é o que a conta dá, e \sqrt{2} não é número redondo. Prefiro o valor exato ao arredondado justamente aqui, porque ele é conferível: quem duvidar reproduz a conta no papel em dez segundos. Discutir ordem de composição em prosa produz concordância sem convicção. Um número impresso na tela encerra a discussão sem apelo a intuição.

1.4 Trocar de dono sem sair do lugar

Você pega uma chave de fenda da bancada e ela passa a acompanhar a sua mão, sem que ninguém recalcule nada.

Solte a chave sobre a mesa e ela passa a pertencer à mesa. No ambiente, essa transferência tem nome: reparentagem. Trocar de pai é a operação por trás de pegar, orientar, encaixar e soltar, e ela cabe em três símbolos de álgebra. O problema é que existe um caminho mais óbvio, ele funciona, e é por isso que ele sobrevive.

O caminho óbvio é copiar. A cada quadro, leia onde está a mão e escreva essa posição na chave, com um deslocamento fixo. Pronto: a chave acompanha a mão. Custa três linhas, não exige álgebra nenhuma e hoje nada o denuncia. A imagem é idêntica, o desempenho é idêntico numa cena pequena, e quem escreveu tem na tela a prova de que resolveu o problema.

Então quando é que a cópia quebra? Em quatro momentos, e todos chegam. O primeiro é quando o objeto intermediário também se move: a mão presa ao braço preso ao corpo, e a cópia lendo só a mão. O segundo é a hora de soltar, quando alguém precisa converter de volta e descobre que não guardou de onde veio. O terceiro é uma inversão de ordem de atualização, e a peça passa a flutuar meio centímetro atrás da mão.

O quarto é o mais chato de diagnosticar. A cópia por quadro dá certo enquanto origem e destino têm a mesma escala. Basta um deles herdar escala de um ancestral para a peça mudar de tamanho ao ser presa. O sintoma é uma engrenagem discretamente maior na mão do que na bancada, e quem procura a causa procura no lugar errado, porque nada no código fala de escala. Os quatro são o mesmo defeito com quatro roupas: a cópia mantém duas fontes de verdade sobre onde a peça está.

Reparentagem que preserva o mundo

A operação pega a posição do objeto no mundo, que não vai mudar, e reescreve essa mesma posição do ponto de vista do novo pai. A transformação local que resulta é a inversa da transformação de mundo do novo pai, composta com a transformação de mundo do objeto. Depois da troca, o produto entre a matriz do novo pai e essa local devolve exatamente a matriz de mundo de antes, de onde a preservação é imediata.

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

O menor exemplo: uma engrenagem a 30 cm do eixo, sobre um tampo parado. Prendê-la ao eixo dá uma transformação local de 30 cm no sistema do eixo, e a engrenagem continua onde estava. A identidade não exige que os dois pais estejam no mesmo nível da árvore, nem que as escalas sejam iguais, nem que o objeto esteja parado. Ela vale por quadro.

O que ela exige, e isto não está na fórmula, é que as duas matrizes estejam atualizadas no instante da conta. Se a matriz de algum ancestral mudou neste quadro e ainda não foi propagada, a inversa é a do quadro anterior. O objeto então salta para onde ele estava, e não para onde está. Pois é: a operação tem três símbolos, e o erro mora nas duas linhas que vêm antes deles.

flowchart LR
    A["Pai antigo: o tampo"] --> OP["Trocar de pai preservando<br/>a posicao de mundo"]
    B["Pai novo: o eixo"] --> OP
    OP --> R["A peca fica exatamente<br/>onde estava"]
    R --> N["Desvio impresso em metros<br/>linha de corte: 1 milimetro"]
    N --> OK["Micrometros: a conta fechou"]
    N --> RUIM["Milimetros: alguma matriz<br/>do caminho estava desatualizada"]
Figura 4: A troca de pai preservando a posição de mundo, com o desvio impresso servindo de conferência.

A biblioteca de renderização já oferece essa operação pronta, e usá-la seria legítimo. Eu a escrevi à mão assim mesmo, por um motivo instrumental. A versão da oficina devolve um número: a distância, em metros, entre onde o objeto estava antes da troca e onde ficou depois. Em tese esse número é zero. Na prática ele é o erro de arredondamento de uma inversão de matriz, algo na casa dos micrômetros — e é por não ser zero que ele serve.

Um desvio de micrômetros diz que a conta rodou e fechou. Um desvio de milímetros diz outra coisa: alguma matriz do caminho até a raiz estava desatualizada. Onde eu ponho a linha de corte? Em 1 milímetro, e o critério é o corpo de quem usa. Peça que pula 1 milímetro aqui está pulando por uma causa que não escala, e a mesma causa produz um salto de um palmo numa cena com hierarquia mais funda.

A demonstração da oficina parece um defeito. Toque no comando que prende a engrenagem ao eixo e ela não se move — continua deitada no tampo, a 30 cm dali. E então começa a girar em torno do eixo, à distância. Por que mostrar assim, em vez de levar a peça até o encaixe? Porque a versão que parece certa cola duas operações na cabeça de quem vê. Trocar de dono e assentar no lugar são coisas diferentes, e confundi-las custa meia tarde de briga com a função errada.

1.5 O relógio que ninguém pediu

Nos gabinetes de PC do fim dos anos 1980 havia um botão etiquetado turbo. Ele deixava a máquina mais lenta.

Apertado, o botão forçava o computador de volta à cadência do IBM PC original, de 4,77 MHz. Ele existia porque uma quantidade enorme de software media tempo contando iterações. Esse software funcionava perfeitamente na máquina para a qual fora escrito. Num processador mais rápido, o mesmo laço terminava antes, e tudo acelerava junto. A indústria acabou pagando por uma decisão de software em hardware: um botão físico, num gabinete de metal.

Duvida que isso ainda aconteça? Troque turbo por taxa de atualização e a história é a mesma. Um ambiente escrito num monitor de 60 Hz e aberto num visor de 90 roda uma vez e meia mais rápido, se contar quadros. E o efeito não é estético. Uma peça que se move rápido demais diante do rosto de alguém é desconforto, e desconforto não se resolve com paciência.

A correção cabe numa multiplicação. O laço mede quanto tempo passou desde a chamada anterior e entrega esse intervalo à cena. Em vez de somar um valor fixo por quadro, a cena multiplica uma velocidade pelo tempo transcorrido. O eixo gira a meia volta por segundo. A 60 imagens por segundo cada quadro recebe cerca de 0,0167 s; a 90, recebe 0,0111 s. Os quadros são mais numerosos, e a volta leva os mesmos dois segundos nos dois aparelhos.

Repare no que essa mudança faz com a natureza do código. A velocidade deixa de ser “quanto somar por quadro”, grandeza sem sentido físico. Passa a ser “quanto andar por segundo”, que se confere com cronômetro. E a conferência é literalmente essa: abra o ambiente em duas máquinas de cadências diferentes e cronometre uma volta do eixo. Se as voltas divergem, o laço está contando quadros. Nenhum instrumento é necessário além de um relógio de pulso.

Só que entregar o intervalo cru tem um problema próprio. O que acontece quando o laço para de ser chamado? Ele para com frequência, por motivos banais: a aba perde o foco, quem está de visor abre o menu do sistema, o sistema decide que a página em segundo plano não merece quadros. Quando o laço volta, o intervalo desde a chamada anterior pode ser de vários segundos.

Entregue esse número à cena e veja o que ela faz. Um mecanismo que gira a meia volta por segundo, recebendo 4 s, gira duas voltas de uma vez. Numa engrenagem sobre a mesa, o efeito é feio. Num objeto que a pessoa segura diante do rosto, é um solavanco no campo de visão, e aí quem paga é o corpo. O relógio da oficina limita o salto em 0,1 s e trata a suspensão como pausa, aceitando de bom grado ficar atrás do tempo real.

O primeiro quadro recebe intervalo zero, de propósito. Não existe quadro anterior a medir, e inventar ali um valor plausível é a origem de um pulinho na primeira imagem que o ambiente mostra. É o tipo de defeito que ninguém procura, porque ninguém suspeita do quadro número 1. Vale o mesmo cuidado com a ordem interna: ajustar o alvo de desenho, medir o tempo, avançar a cena, desenhar. Desenhar antes de avançar entrega ao olho o estado do quadro anterior — atraso gratuito que soma à latência do aparelho e que o corpo reclama primeiro.

flowchart LR
    Q["Quem pede o proximo quadro:<br/>a janela ou a sessao imersiva"] --> A["Ajustar o alvo<br/>de desenho"]
    A --> M["Medir o tempo desde<br/>a chamada anterior"]
    M --> L["Limitar o salto<br/>em 0,1 s"]
    L --> AV["Avancar a cena pelo<br/>tempo transcorrido"]
    AV --> D["Desenhar"]
    D --> Q
Figura 5: A ordem das etapas dentro de um quadro e quem agenda a chamada seguinte em cada regime.

Falta a pergunta que parece administrativa e é a de maior consequência: quem chama a função? Na página comum, quem chama é a janela do navegador, em sincronia com o monitor. Numa sessão imersiva, a janela deixa de mandar. O visor tem cadência própria, e quem agenda a chamada seguinte é a sessão. O erro que decorre disso é dos mais frustrantes de diagnosticar. O ambiente funciona no computador de mesa e congela ao entrar no visor, enquanto a página segue respondendo normalmente. A biblioteca oferece um laço que troca de fonte sozinho, e a oficina o adota já — três capítulos antes de existir sessão para justificá-lo.

1.6 O envelope e o medidor que mora dentro dele

Existe uma ordem de trabalho que parece sensata e produz ambientes que só rodam na máquina de quem os escreveu.

Ela é esta: construa a cena, ponha o conteúdo, meça no fim e corte o que estiver caro. Eu discordo por um motivo de aritmética. Teto declarado depois que o ambiente já está montado não é orçamento — é laudo do que já foi gasto, e a essa altura cortar significa remodelar. Por isso o teto entra aqui, num capítulo em que a cena tem cinco caixas e nada pesado a que atribuir a culpa.

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

A imagem do envelope é literal, e a razão é mecânica. Um quadro que atrasa não é entregue atrasado. Ele perde a janela de exibição, o aparelho reapresenta a imagem anterior, e o trabalho feito para produzi-lo vai fora. Você pagou o custo e não recebeu a imagem. Isso separa o tempo real de quase tudo o mais que se escreve: numa consulta a banco de dados, resposta que demora o dobro é resposta lenta; aqui, o atraso empurra a fila e se acumula em imagens não entregues.

E quem paga a conta, no regime imersivo, não é a paciência de quem olha. É o sistema vestibular. Imagem que não chega no ritmo esperado, com a cabeça em movimento, é uma das causas documentadas de mal-estar em ambiente imersivo. Ninguém abandona uma sessão porque a animação está menos lisa. Abandona porque passou mal.

De onde saem os onze milissegundos? De 90 imagens por segundo, que é a cadência do visor. Até aí é aritmética. Aqui há uma sutileza que custa caro quando passa despercebida: o teto tem duas metades, e elas vêm de aparelhos diferentes. O tempo disponível vem do aparelho de maior cadência. O trabalho que cabe nesse tempo vem da máquina mais fraca, o computador de mesa de vídeo integrado, que é a mais comum do laboratório.

flowchart TD
    V["Aparelho de maior cadencia:<br/>o visor a 90 imagens por segundo"] --> TE["Tempo por quadro:<br/>11,1 ms"]
    F["Maquina mais fraca:<br/>video integrado, sem placa dedicada"] --> TR["Trabalho que cabe<br/>dentro desse tempo"]
    TE --> EN["O envelope do quadro"]
    TR --> EN
    EN --> C["Custo:<br/>o que o nosso laco gasta<br/>e o que podemos cortar"]
    EN --> I["Intervalo:<br/>o que o corpo percebe<br/>entre duas imagens"]
Figura 6: As duas metades do teto, com origens em aparelhos opostos, e as duas grandezas que o painel exibe.

Orçar só pelo computador de mesa dá 16,7 ms, que é o intervalo de 60 imagens por segundo. Parece folga e é armadilha: o ambiente escrito assim cabe ali e estoura no visor em 50%. Eu escolho orçar pelo visor mesmo sabendo que quase todo o desenvolvimento acontece na outra máquina, e o preço dessa escolha é real — menos objetos, menos efeitos, menos capricho onde haveria folga. Aceito porque as duas falhas não são simétricas. Folga desperdiçada em janela custa aparência; envelope estourado no visor custa mal-estar em quem está com o aparelho no rosto.

1.6.1 Duas grandezas que parecem a mesma

O custo é o tempo que o nosso trabalho de quadro consome: montar as matrizes, avançar a cena, emitir os comandos de desenho. É o que escrevemos, e portanto é o que podemos cortar. O intervalo é o tempo real decorrido entre duas imagens entregues, e é o que o corpo percebe. Nele entram a placa de vídeo, o compositor do sistema operacional e tudo o mais com que dividimos o aparelho.

O que fazer quando o custo está em 3 ms e o intervalo em 22? A tentação é otimizar o código, e ela é exatamente o engano. Custo baixo com intervalo alto é sintoma de gargalo fora do nosso laço. Sem as duas colunas lado a lado, o grupo passa a tarde otimizando a metade que já estava barata. O caso inverso também informa: custo em 14 ms com intervalo em 16 diz que o gargalo é nosso e está ao alcance da mão.

E como se resume o desempenho de milhares de quadros num número? A resposta óbvia é a média, e ela é a pior escolha possível. Um ambiente que entrega 89 quadros bons e um quadro de 200 ms tem média excelente. Ele também tem um solavanco visível, e é o solavanco que a pessoa leva para casa. O corpo não tira média: ele registra a interrupção. Por isso a janela de observação da oficina é curta e circular, com 120 quadros sobrescritos em rodízio.

Três números valem mais que a média sozinha, e a oficina exibe os três. O custo médio, que diz onde estamos. O pior intervalo da janela, que é o engasgo. E a proporção de quadros acima do teto, que diz se o estouro é acidente ou hábito. Um ambiente com 2% de quadros estourados tem um problema esporádico; com 30%, tem um problema de projeto. Aqui o envelope já mostra o próprio limite, e é honesto dizê-lo antes que ele cobre: ele trata custo como média, e o corpo não.

1.6.2 Um medidor que some justamente onde faz falta

Um número exibido fora da cena desaparece assim que a tela sobe para os olhos. Dentro do visor não existe página, não existe barra lateral e não existe console. Existe só o que foi desenhado. Por isso o painel da oficina é um retângulo de 0,62 m por 0,34 m, preso ao tampo da bancada e virado para quem trabalha. Ele exibe o passo da montagem, o aparelho detectado e as leituras do orçamento.

Por que preso ao tampo, e não à câmera? Preso à câmera ele acompanharia a cabeça e deixaria de ser objeto do mundo. Texto grudado no rosto, dentro de um visor, é receita conhecida de desconforto: o olho não consegue ignorá-lo nem focá-lo à vontade, porque ele nunca sai do campo central. Preso ao tampo, ele é uma placa na bancada. Você olha quando quer.

Agora o problema divertido. Um painel que informa o custo do quadro gasta custo do quadro, e enviar a textura de texto para a placa é das operações caras. Um medidor que estourasse o orçamento enquanto informa o orçamento mentiria, porque o número lido incluiria o custo de produzi-lo. A saída é redesenhar quatro vezes por segundo. O segundo motivo é mais simples que o primeiro: número que muda 60 vezes por segundo não se lê, borra.

Antes de qualquer peça pesada entrar, existe um lugar em que o orçamento estoura, e ele não tem nada a ver com geometria. Telas densas pedem mais de um pixel físico por pixel de layout — um monitor comum pede um, um celular moderno pode pedir três. O custo cresce com a área, e a área cresce com o quadrado da densidade. Triplicar a densidade multiplica o trabalho por nove, sem um único triângulo acrescentado. Por isso a oficina limita a densidade em 2, e o limite é constante nomeada.

Qual é a lição de ordem aqui? Que a primeira pergunta sobre desempenho não é quantos triângulos a cena tem. É quantos pixels estamos pintando. Um ambiente simples numa tela densa custa mais que um ambiente pesado numa tela comum, e o grupo que começa contando triângulos passa longe da causa.

O critério que separa composição de conteúdo

Um arquivo de composição não tem conceito próprio: ele conecta. Se ele passar a decidir alguma coisa — que velocidade o eixo tem, que teto o orçamento usa, que peça nasce onde —, aquela decisão vazou para lá e pertencia a outro arquivo. O teste é conferível em segundos: conte quantos arquivos precisam ser abertos para trocar a cor de fundo da cena. Se a resposta for um, a separação está de pé. Se for três, algum arquivo guardou uma decisão que não era dele.

1.7 As cinco peças que sustentam o resto

A primeira cena executável do percurso tem cinco caixas cinzentas, e o que interessa nela não é nenhuma das cinco.

Todo o argumento deste capítulo cabe em cinco arquivos pequenos, e vale ler os cinco com o argumento na cabeça. Nenhum deles desenha nada de bonito. Juntos, eles decidem o que os próximos onze capítulos vão poder assumir.

O primeiro transforma a discussão sobre ordem em número. Ele aplica as mesmas duas operações ao mesmo ponto, nas duas ordens possíveis, e mede a distância entre os destinos.

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,
  );

  // 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));

  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)})`;
}

Repare no comentário sobre a leitura da multiplicação. Ele está no código, e não na prosa, porque é ali que a armadilha mora — e porque quem tropeça nela às onze da noite tem o arquivo aberto, não o livro.

O segundo monta a árvore. Cada parentesco dele responde à frase que decide a hierarquia: mover a bancada leva junto o que está sobre ela.

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);

  // 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);

  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>();
  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);
  }

  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 };
}

Note que as peças nascem soltas sobre o tampo, e não já encaixadas no suporte. Nascer no destino apagaria a operação que o capítulo existe para ensinar. Note também 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é.

O terceiro é a reparentagem, com a identidade escrita à mão e o desvio devolvido em metros.

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();

/**
 * Torna `filho` filho de `novoPai` sem que ele saia do lugar em que estava no
 * mundo. Devolve o desvio residual em metros, que é o erro de arredondamento da
 * conta e deve ficar na casa dos micrômetros.
 *
 * 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);
}

/** 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;
}

Veja as duas primeiras linhas do corpo da função. São elas que atualizam a cadeia de matrizes até a raiz, e é a ausência delas que faz a peça pular. Três símbolos de álgebra, duas linhas de burocracia, e a burocracia é onde mora o defeito.

O quarto é o relógio, com o teto de salto e o primeiro quadro de intervalo zero.

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 };
    }

    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 };
  }

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

O intervalo real continua sendo devolvido ao lado do intervalo limitado, e a separação é proposital. O primeiro serve à medição; o segundo, à simulação. Confundi-los faria o painel informar um engasgo que a cena nunca sofreu.

O quinto guarda o orçamento, com as duas grandezas separadas e a janela circular que se recusa a diluir o engasgo.

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.
// ---------------------------------------------------------------------------

/** 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;

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 {
    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;
  }

  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}`,
  ];
}

Os dois tetos aparecem como constantes nomeadas, uma por classe de aparelho. É a decisão sobre as duas metades do envelope, escrita onde ela pode ser lida e contestada, em vez de dissolvida numa divisão no meio de uma função.

1.8 Cinco caixas cinzentas e três decisões

O que ficou pronto não impressiona ninguém. É por isso mesmo que ele precisa ser defendido por argumento.

Uma bancada sem textura, um suporte, cinco peças de geometria crua e uma placa de texto no tampo. Mostre isso a alguém de fora e a reação será educada. Mostre a quem vai construir sobre a cena e a conversa muda. Ali existe uma árvore em que cada parentesco tem razão de projeto, um relógio que não mente sobre o tempo e um teto declarado antes do primeiro polígono caro.

flowchart LR
    A["Arvore com cada parentesco<br/>justificado pelo dominio"] --> S["Cinco caixas cinzentas<br/>e um painel de texto"]
    R["Relogio que avanca a cena<br/>pelo tempo transcorrido"] --> S
    O["Teto declarado antes do<br/>primeiro poligono caro"] --> S
    S --> Q["A pergunta seguinte:<br/>se a mesma imagem custa<br/>3 ms ou 14 ms,<br/>o que se compra com a diferenca?"]
Figura 7: O que o capítulo deixa pronto e a pergunta que ele empurra para o capítulo seguinte.

A imagem que a cena produz não é evidência de nenhuma das três decisões. Uma cena com coordenadas absolutas desenha isto igualzinho. Um laço que conta quadros anima isto igualzinho, na sua máquina. Daí decorre uma consequência que vale para o percurso inteiro: decisões de estrutura se defendem por argumento e por conferência, jamais por captura de tela. O mesmo vídeo sairia de uma implementação errada nos três aspectos.

E as três têm conferência mecânica, o que é a parte boa da história. A árvore se confere lendo a estrutura impressa e justificando cada nível com uma frase sobre o domínio. O relógio se confere cronometrando uma volta do eixo em duas máquinas de cadências diferentes. O teto se confere lendo o painel dentro da cena, com o pior intervalo ao lado da média. Nenhuma das três exige instrumento que você não tenha em casa.

Três números que este texto poderia ter dado e não deu. Quantos triângulos e quantas chamadas de desenho cabem no envelope da máquina de vídeo integrado do laboratório? Não sei — a medida exige a cena carregada, e a cena tem cinco caixas. Qual é a taxa de quadros a perseguir como alvo? Não está fixada, e a decisão de não fixá-la é minha: alvo numérico sem medição é número que se persegue por obediência. E a latência entre gesto e imagem exige equipamento próprio. Número chutado ganha, pela aparência de precisão, a autoridade de medição, e vira requisito que decide cortes que ninguém justificou.

O que existe, então, é o instrumento, e não a medida. Isso é menos do que se gostaria e é mais do que costuma existir. O painel está na cena, com as três leituras, desde antes de haver o que medir. Quando a primeira peça pesada entrar, o número vai mudar diante de quem estiver olhando, na mesma sessão, e a mudança vai ter causa identificável.

Repare no que a ordem inversa produziria. Construir a cena bonita primeiro e medir depois deixaria você diante de um número ruim sem jeito nenhum de saber qual das quarenta decisões o produziu. Com o instrumento montado antes, cada decisão entra sozinha e o efeito dela aparece isolado. A malha mais densa custa isto, o material com reflexo custa aquilo. É a diferença entre depurar e adivinhar, e ela custou este capítulo inteiro de imagem feia.

A pergunta que fica aberta é curta e vai ser respondida com números. Se a mesma imagem pode custar 3 ms ou 14, o que exatamente se compra com a diferença? É isso que vem a seguir, com geometria de verdade, materiais que respondem à luz e a distinção entre a malha que se vê e o volume com que se colide.

O que vem agora é a tradução de tudo isso para o seu próprio ambiente.

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, e vale escrevê-la enquanto a cena ainda é pequena e dá para conferir 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.