Pular para o conteúdo principal

1 Realidade virtual não imersiva

Você abre um endereço no computador, ou no celular, e a página mostra uma mesa de oficina vista de frente. Acima do desenho, três frases dizem como girar a mesa, chegar perto e ir até cada peça; abaixo, há botões com nomes: “A cena inteira”, um para cada peça do mecanismo que está sobre a mesa e “A prateleira do fundo”. Nada disso pediu aparelho preso à cabeça nem programa instalado. Ao lado do aparelho preso à cabeça, porém, este jeito de ver sai perdendo em quase tudo o que se costuma comparar: não há nada em volta de quem olha, o corpo fica de fora, e a mesa continua do outro lado do vidro.

O ambiente é o do módulo sobre ativos, formatos de troca e orçamento de cena, inteiro: as cinco peças, a morsa presa ao tampo, a borda com 206 parafusos e a prateleira com 24 peças de reposição. O que muda é quem decide de onde a cena é vista.

  • A cena é tudo o que existe no ambiente: as peças, a mesa, as luzes e a câmera que olha para elas. Cada objeto da cena é um nó, e um nó pode ficar pendurado em outro e acompanhá-lo quando ele se move, como a morsa pendurada no tampo.
  • Cada imagem que um aparelho mostra é um quadro.
  • O visor é o aparelho preso à cabeça que cobre os olhos e põe o ambiente em volta de quem o usa: quando a cabeça gira, a imagem acompanha.
  • Um regime é cada modo de servir o mesmo ambiente. O módulo sobre o contínuo entre o sintético e o real declarou três, pelo que cada um faz com a sala em volta de quem observa. O regime imersivo, o do visor, esconde a sala e põe a cena no lugar dela. O regime em janela mostra a cena numa tela comum e não mexe em nada em volta de quem olha: é a realidade virtual não imersiva do título. O de realidade aumentada mistura a cena com a imagem da sala que a câmera do celular capta.
  • As cinco peças do domínio são as do mecanismo que o ambiente ensina a montar: o corpo, o eixo, a engrenagem grande, a engrenagem pequena e a tampa do mecanismo. Os parafusos e as peças de reposição estão na cena, mas não entre elas.

1.1 A janela embaixo de tudo

Até aqui, a câmera ficava onde a cena a punha ao abrir. Agora há um caminho de navegação completo: toda peça do domínio pode ser trazida para o meio da tela, inteira, sem ninguém ao lado explicando o que fazer.

Diante de um projeto que vai ter visor, a janela parece um degrau provisório, que se monta para chegar ao andar de cima e depois se tira. Comparada aos outros regimes pelo que cada um entrega a quem está com o melhor aparelho, ela perde sempre: não tem a profundidade que os dois olhos veem, não sabe a altura de ninguém, não deixa ninguém esticar o braço até uma peça. A comparação muda quando a pergunta passa a ser “o que cada regime entrega quando falta alguma coisa”: o visor, a permissão para usar a câmera do celular, a vez na fila dos visores.

“Escada rolante parada ainda é escada; ambiente que só existe com o visor no rosto não é nada sem ele.”

A escada rolante sem energia ainda leva as pessoas ao outro andar, a pé. A janela é a escada parada: oferece menos, mas qualquer pessoa consegue usar.

1.1.1 Três fatos e uma definição

O visor pode não ligar, o celular pode negar o acesso à câmera dele, o computador pode não oferecer a sessão imersiva, a ligação por onde o programa entrega cada quadro ao visor. O ambiente passa então para um regime que pede menos do aparelho. Essa passagem é a queda entre regimes, ou degradação, e diz-se que o ambiente degrada.

Num laboratório com três visores e quarenta pessoas, três fatos decidem o lugar da janela, e nenhum fala de qualidade de imagem:

Fato O que ele diz
os aparelhos três visores servem três pessoas ao mesmo tempo; a janela abre em todos os computadores e celulares da sala de uma vez
a queda entre regimes sem o regime melhor, o ambiente cai para um que pede menos, e a queda termina na janela
quem assiste a apresentação é vivida por quem está com o visor e assistida por todos os outros, numa tela
NotaDefinição — Caso base

O caso base de um ambiente servido em mais de um regime é o regime que precisa funcionar sozinho, antes de qualquer outro existir, porque é para ele que todos os outros caem quando falta o aparelho, a permissão ou a sessão de que dependem.

O que o distingue é a direção da dependência. Os outros regimes acrescentam ao caso base, e o caso base não depende de nenhum deles. Entregue pela metade, ele não espera os outros ficarem prontos: transforma cada falta de aparelho numa tela que não leva a lugar nenhum.

Neste percurso, o caso base é o regime em janela, a realidade virtual não imersiva: a cena mostrada numa tela comum, sem equipamento além do computador ou do celular.

“Entregue pela metade” é a janela que desenha a mesa e não deixa chegar a todas as peças: no dia em que o visor não liga, a engrenagem pequena fica pequena demais na tela das quarenta pessoas.

1.1.2 A ordem de construção corre ao contrário da dependência

A construção vai da janela ao regime imersivo, e a dependência vai do regime imersivo à janela. No diagrama, as setas cheias são a construção, e as tracejadas, a queda.

%%{init: {"flowchart": {"useMaxWidth": false}, "sequence": {"useMaxWidth": false}, "class": {"useMaxWidth": false}, "state": {"useMaxWidth": false}, "er": {"useMaxWidth": false}}}%%
flowchart LR
    J["Regime em janela<br/>caso base"]
    I["Regime imersivo<br/>acrescenta o visor"]
    A["Realidade aumentada<br/>acrescenta a sala"]
    J -->|construído primeiro| I
    J -->|construído primeiro| A
    I -.->|cai quando falta o visor ou a sessão imersiva| J
    A -.->|cai quando falta a permissão da câmera do celular| J
Figura 1: A construção sai da janela; a queda volta para ela.

A janela vem primeiro porque os outros vão depender dela, e não porque vai ser descartada: com o visor pronto, ela continua sendo o destino da queda e o que vê quem assiste. O engano oposto é apresentá-la no lugar de um regime imersivo que nunca foi construído. Ninguém apresenta a escada parada como se estivesse rodando, e a janela tem de dizer, na própria página, o que não oferece (Seção 1.6).

Antes de seguir, responda de cabeça. No dia da apresentação, o visor de quem apresenta não liga. Para onde vão as quarenta pessoas, e o que a janela precisa ter para que nenhuma delas fique diante de uma tela que não leva a lugar nenhum?

1.2 A câmera presa a um ponto

A câmera da abertura está no ponto (0;\ 1{,}55;\ 1{,}50) e olha para o ponto (0;\ 0{,}95;\ 0). Os três números de cada ponto dizem, em metros, quanto ele fica para o lado (x), para cima (y) e para a frente da mesa (z), o lado de quem trabalha nela. O ponto para onde a câmera olha é o alvo, aqui 5 cm acima do meio do tampo, que está a 90 cm do chão. Do alvo até a câmera, o caminho é a diferença:

(0;\ 1{,}55;\ 1{,}50) - (0;\ 0{,}95;\ 0) = (0;\ 0{,}60;\ 1{,}50).

Desse caminho, 0,60 m para cima e 1,50 m para a frente, saem três números:

  1. A distância até o alvo, o comprimento do caminho: \sqrt{0{,}60^2 + 1{,}50^2} = \sqrt{2{,}61} \approx 1{,}616 m, perto de 1,62 m.
  2. A elevação, o ângulo do caminho com o chão, visto de lado: o seno é a subida dividida pela distância, 0{,}60 / 1{,}616 \approx 0{,}371, e o ângulo, 21,8 graus.
  3. O azimute, o giro em volta do alvo visto de cima, a partir da frente da mesa: zero, porque o caminho não tem parte para o lado.
NotaDefinição — Câmera em órbita

Uma câmera em órbita é uma câmera que gira em volta de um ponto, o alvo, sempre olhando para ele. A posição dela fica guardada em três números medidos a partir do alvo: o azimute \alpha, o giro em volta do alvo visto de cima; a elevação \varepsilon, o ângulo com que a câmera sobe acima da altura do alvo; e a distância d até o alvo.

Com o alvo em (x_a, y_a, z_a), a posição da câmera é

x = x_a + d\cos\varepsilon\,\operatorname{sen}\alpha, \qquad y = y_a + d\operatorname{sen}\varepsilon, \qquad z = z_a + d\cos\varepsilon\,\cos\alpha.

Cada gesto muda os ângulos ou a distância, e a posição sai dos três números. Enquanto a órbita estiver instalada, a posição escrita por qualquer outro caminho dura só até o gesto seguinte, que a reescreve a partir dos três números: quem quiser a câmera noutro lugar pede à órbita um alvo e uma distância novos.

A elevação e a distância têm travas, o menor e o maior valor que a órbita aceita, e cada trava existe por um defeito que ela evita.

O produto d\cos\varepsilon é a sombra que o segmento entre a câmera e o alvo faz no chão, e o azimute a reparte entre x e z. Na câmera da abertura, a conta devolve x = 0, y = 0{,}95 + 1{,}62 \times 0{,}371 \approx 1{,}55 e z = 1{,}62 \times \cos 21{,}8° \approx 1{,}50: o mesmo ponto em que a câmera estava.

a posição tirada dos três números, em src/bancada/core/orbita.ts
function aplicar(): void {
  const a: number = MathUtils.degToRad(azimute);
  const e: number = MathUtils.degToRad(elevacao);
  // O raio visto de cima, a sombra dele no chão. É esse comprimento que o
  // azimute reparte entre x e z.
  const sombra: number = raio * Math.cos(e);
  camera.position.set(
    alvo.x + sombra * Math.sin(a),
    alvo.y + raio * Math.sin(e),
    alvo.z + sombra * Math.cos(a),
  );
  camera.lookAt(alvo);
  escritas += 1;
}

No código, raio é a distância até o alvo, e os ângulos passam de graus para radianos antes do seno e do cosseno. O three.js, a biblioteca de desenho do projeto, traz um controlador de órbita pronto, que trata casos que esta órbita deixa de fora, como a câmera que continua girando um pouco depois que a mão para. Fora de um projeto de estudo, o controlador pronto seria a escolha; aqui a conta é escrita à mão para mostrar a trigonometria.

1.2.1 A partida medida e as quatro travas

Ao instalar a órbita, o programa faz a conta ao contrário: mede o caminho do alvo até a câmera já posicionada e tira dele 1,62 m, 21,8 graus e azimute zero. A primeira escrita da câmera pela órbita a devolve ao mesmo lugar.

%%{init: {"flowchart": {"useMaxWidth": false}, "sequence": {"useMaxWidth": false}, "class": {"useMaxWidth": false}, "state": {"useMaxWidth": false}, "er": {"useMaxWidth": false}}}%%
flowchart LR
    P["Câmera onde a cena a pôs<br/>1,55 m de altura, 1,50 m à frente"]
    A["Alvo de partida da órbita<br/>0,95 m de altura, no meio da mesa"]
    C["Caminho do alvo à câmera<br/>0,60 m acima, 1,50 m à frente"]
    N["Distância 1,62 m<br/>elevação 21,8 graus<br/>azimute 0 grau"]
    E["Primeira escrita:<br/>a câmera no mesmo lugar"]
    P --> C
    A --> C
    C --> N --> E
Figura 2: A órbita tira os três números de partida da câmera que já está no lugar.

Se os números de partida viessem escritos no programa da órbita, a câmera saltaria, logo ao abrir, do lugar que quem montou a cena escolheu. Mas sobra uma ligação entre a cena e a órbita: o alvo aparece duas vezes, como o ponto para onde a cena aponta a câmera ao abrir e como o alvo de partida da órbita, e mudar um sem o outro faz a vista mudar sozinha ao abrir a página.

A partida é o começo da função que instala a órbita:

os três números tirados da câmera, em src/bancada/core/orbita.ts
// A órbita começa onde a câmera já está. Os três números saem da posição que o
// palco deu à câmera, medida a partir do alvo, e não de valores escritos aqui.
const alvo: Vector3 = ALVO_INICIAL.clone();
const deslocamento: Vector3 = camera.position.clone().sub(alvo);
let raio: number = MathUtils.clamp(
  deslocamento.length(),
  TRAVAS_DA_ORBITA.raioMinimoM,
  TRAVAS_DA_ORBITA.raioMaximoM,
);
let azimute: number = MathUtils.radToDeg(Math.atan2(deslocamento.x, deslocamento.z));
let elevacao: number = MathUtils.clamp(
  MathUtils.radToDeg(Math.asin(deslocamento.y / deslocamento.length())),
  TRAVAS_DA_ORBITA.elevacaoMinimaGraus,
  TRAVAS_DA_ORBITA.elevacaoMaximaGraus,
);
let escritas: number = 0;

deslocamento é o caminho do alvo até a câmera, e o palco do comentário é a parte do programa que cuida da tela e da câmera. ALVO_INICIAL e a chamada camera.lookAt(0, 0.95, 0) do arquivo que cria a câmera são os dois que precisam continuar iguais.

O azimute não tem trava, porque dar a volta inteira na mesa não estraga nada. Cada trava evita um defeito:

Trava Valor O que acontece sem ela
elevação mínima 0 grau a câmera desce abaixo da altura do alvo, e o tampo fica entre ela e as peças que estão em cima dele
elevação máxima 85 graus passando de 90, a imagem dá meia-volta e o arrasto passa a girar ao contrário
distância mínima 0,35 m o alvo de partida fica dentro do corpo, a peça do meio do tampo, e a câmera entra nele
distância máxima 8 m o desenho só mostra o que está a menos de 50 m da câmera; a roda poderia afastar a câmera mais de 50 m do alvo, e a cena sumiria da tela

A 90 graus, a câmera está exatamente em cima do alvo, e “olhar para o alvo” já não diz qual lado da imagem fica em cima. Passando de 90, ela vai para o outro lado da mesa, por cima, e a imagem vira. Os valores de 0,35 m e de 8 m são escolha, e não saem de conta. O primeiro afasta a câmera do corpo com sobra e ainda deixa a menor peça inteira na tela (Seção 1.4.2), e o segundo só precisa ficar abaixo dos 50 m em que o desenho é cortado.

as travas da órbita, em src/bancada/core/orbita.ts
/** As travas da órbita: até onde ela deixa a câmera ir. Ângulos em graus, distâncias em metros. */
export const TRAVAS_DA_ORBITA = {
  /** A câmera não desce abaixo da altura do alvo: dali para baixo, o tampo fica entre ela e as peças. */
  elevacaoMinimaGraus: 0,
  /**
   * Para cinco graus antes de ficar exatamente acima do alvo. Ali, olhar para o
   * alvo deixa de dizer qual lado da imagem fica em cima, e passar dali vira a
   * imagem de cabeça para baixo e inverte o sentido do arrasto.
   */
  elevacaoMaximaGraus: 85,
  /** O alvo inicial fica dentro do corpo, a peça do meio do tampo; sem esta trava, a câmera entraria nele. */
  raioMinimoM: 0.35,
  /** Sem esta trava, a roda levaria a câmera além dos 50 m em que o palco corta o desenho, e a cena sumiria. */
  raioMaximoM: 8,
} as const;

Os 50 m são declarados no arquivo que cria a câmera, e não no da órbita: uma trava de distância acima deles passaria sem aviso.

1.3 Gestos que só mudam números

No computador com mouse, no celular ou no tablete com caneta, a plataforma em que a página roda avisa o programa do mesmo jeito: alguma coisa encostou na tela, se moveu, soltou. Cada aviso traz a posição na tela, em pixels, e um número que diz qual dedo, mouse ou caneta o mandou, e o mesmo código atende os três aparelhos.

Dois detalhes decidem se o gesto chega inteiro. Se o mouse sai da cena no meio de um arrasto e o botão é solto lá fora, o aviso de soltura precisa chegar mesmo assim; senão, o programa continuaria achando que o botão está apertado, e o mouse passando por cima, sem botão, voltaria a girar a cena. E, no celular, arrastar um dedo rola a página, e afastar dois dedos aumenta a página inteira: a página precisa dizer à plataforma que, sobre a cena, o gesto é do programa.

No navegador, os avisos se chamam eventos de ponteiro: pointerdown quando algo encosta, pointermove quando se move, pointerup quando solta. O número que identifica cada ponteiro (o mouse, cada dedo, a caneta) é o pointerId. A órbita guarda, num mapa indexado por esse número, a última posição de cada ponteiro apertado, e é isso que a deixa medir a distância entre dois dedos.

o ponteiro que encosta na cena, em src/bancada/core/orbita.ts
function aoDescer(evento: PointerEvent): void {
  ponteiros.set(evento.pointerId, { x: evento.clientX, y: evento.clientY });
  // A referência da pinça é medida no instante em que o segundo dedo pousa.
  // Medida só no primeiro movimento, o trecho inicial do gesto se perderia.
  distanciaAnterior = distanciaEntreDoisDedos();
  // Os movimentos deste ponteiro continuam chegando a este elemento mesmo se o
  // cursor sair dele no meio do arrasto.
  elemento.setPointerCapture(evento.pointerId);
}

setPointerCapture resolve o primeiro detalhe; o segundo é elemento.style.touchAction = 'none', em outra linha da função de instalação.

1.3.1 Arrastar gira

No programa, cada pixel que a mão anda gira a câmera 0,35 grau: para o lado, muda o azimute; para cima ou para baixo, a elevação. O azimute diminui quando a mão vai para a direita: a câmera anda para a esquerda em volta do alvo, e a mesa gira para a direita na tela, como se a mão a empurrasse. Arrastar para baixo aumenta a elevação, e a câmera sobe e mostra mais do tampo. Um arrasto de 100 pixels para a direita e 40 para baixo, feito em dez movimentos, deixou a órbita assim:

Número Antes Depois De onde vem a diferença
azimute 0,0 grau −35,0 graus 100 \times 0{,}35 = 35, para o lado que diminui
elevação 21,8 graus 35,8 graus 21{,}8 + 40 \times 0{,}35 = 35{,}8
distância 1,62 m 1,62 m arrastar não muda a distância

Um arrasto de 200 pixels para baixo pediria 21{,}8 + 70 = 91{,}8 graus, mas a trava seguraria a elevação em 85.

o giro pelo arrasto, em src/bancada/core/orbita.ts
function girar(dx: number, dy: number): void {
  // Arrastar para a direita leva a câmera para a esquerda em volta do alvo, e a
  // cena parece girar para a direita, junto com a mão.
  azimute -= dx * GRAUS_POR_PIXEL;
  elevacao = MathUtils.clamp(
    elevacao + dy * GRAUS_POR_PIXEL,
    TRAVAS_DA_ORBITA.elevacaoMinimaGraus,
    TRAVAS_DA_ORBITA.elevacaoMaximaGraus,
  );
  aplicar();
}

O sinal de menos em azimute -= dx * GRAUS_POR_PIXEL é o “diminui quando a mão vai para a direita”, e aplicar() escreve a câmera a cada movimento.

1.3.2 A roda e a pinça multiplicam a distância

Um passo da roda do mouse para baixo multiplica a distância por 1,12, e a câmera se afasta; um para cima divide por 1,12: 1{,}62 \times 1{,}12 = 1{,}81 m, e 1{,}81 / 1{,}12 = 1{,}62 m. Com 1,12 para afastar e 0,88 para aproximar, cada ida e volta multiplicaria a distância por 1{,}12 \times 0{,}88 = 0{,}9856, e, partindo de 1,62 m, a câmera chegaria 22 cm mais perto em dez idas e voltas sem que ninguém pedisse. A rolagem de lado do trackpad chega como um passo da roda com valor vertical zero. Testada só pela pergunta “o valor é positivo?”, ela cairia no “não”, o caso da roda para cima, e aproximaria a câmera; por isso o programa testa o zero antes e não mexe.

No navegador, a roda chega como evento wheel, com o valor vertical em deltaY:

a roda, em src/bancada/core/orbita.ts
function aoRodar(evento: WheelEvent): void {
  // Sem isto, a roda também rolaria a página enquanto a câmera se move.
  evento.preventDefault();
  // A rolagem de lado, que o trackpad produz, chega com deltaY zero.
  if (evento.deltaY === 0) {
    return;
  }
  // Afastar multiplica e aproximar divide pelo mesmo fator: um passo para cada
  // lado devolve a câmera à mesma distância.
  aproximar(evento.deltaY > 0 ? FATOR_DA_RODA : 1 / FATOR_DA_RODA);
}

evento.preventDefault() impede a página de rolar, e o ouvinte da roda, aoRodar, é registrado fora do trecho com { passive: false }, que declara que ele vai chamar preventDefault(). aproximar multiplica a distância pelo número recebido: com 1,12, apesar do nome, ela afasta.

A pinça é o gesto de afastar ou aproximar dois dedos na tela; afastar os dedos aproxima a câmera, como quem abre os dedos sobre uma foto. A distância da câmera é multiplicada pela razão entre a distância entre os dedos de antes e a de agora. De 100 para 200 pixels, a razão é 1/2, e a câmera vai de 1,62 m para 0,81 m, com o gesto feito de uma vez ou em dez movimentos.

A razão rende igual perto e longe, e a mesma pinça leva 8 m a 4 m; um tanto fixo de metros por pixel moveria a câmera o mesmo tanto em qualquer distância: quase nada para quem está a 8 m, e o bastante para atravessar a peça a 0,35 m. A distância “de antes” se mede quando o segundo dedo pousa. Se ela fosse medida no primeiro movimento, a referência seria 110 pixels, o pedaço de 100 para 110 se perderia, e a câmera pararia em 1{,}62 \times 110/200 = 0{,}89 m, e não em 0,81.

o movimento de um ponteiro, em src/bancada/core/orbita.ts
function aoMover(evento: PointerEvent): void {
  const anterior: { x: number; y: number } | undefined = ponteiros.get(evento.pointerId);
  // Mouse passando sem botão apertado: não há arrasto a seguir.
  if (anterior === undefined) {
    return;
  }
  const dx: number = evento.clientX - anterior.x;
  const dy: number = evento.clientY - anterior.y;
  ponteiros.set(evento.pointerId, { x: evento.clientX, y: evento.clientY });

  if (ponteiros.size >= 2) {
    const agora: number = distanciaEntreDoisDedos();
    // Dedos que se afastam dão razão menor que 1, e o raio encolhe: a câmera
    // chega perto. A razão, e não a diferença em pixels, faz o gesto render o
    // mesmo perto e longe do alvo.
    if (distanciaAnterior > 0 && agora > 0) {
      aproximar(distanciaAnterior / agora);
    }
    distanciaAnterior = agora;
    return;
  }

  girar(dx, dy);
}

Com dois ponteiros apertados, a função chama aproximar(distanciaAnterior / agora); com um, chama girar. distanciaAnterior é medida em aoDescer, no pouso de cada ponteiro.

1.3.3 Quem escreve a câmera

O laço é a parte do programa que a cada quadro atualiza o que mudou e manda desenhar de novo. A conta da órbita podia rodar ali, mas, com a página parada, repetiria em todo quadro a posição do quadro anterior. O programa escreve a câmera só quando um gesto chega ou quando um botão pede um lugar novo. A folha, o texto que o programa escreve na tela abaixo do desenho, conta as escritas da câmera e os quadros desenhados. Numa execução, a página parada desenhou mais de mil quadros e escreveu a câmera uma vez só, na instalação da órbita:

O que aconteceu Escritas da câmera, no total
a página abriu, e ninguém mexeu 1
um arrasto de dez movimentos 11
um passo da roda para baixo, um para cima 13
uma rolagem de lado no trackpad 13
três botões, um depois do outro 16

O ganho é de diagnóstico, e não de velocidade: um número que cresce com a página parada denuncia um código que escreve a câmera sem ninguém pedir. Escrever a câmera a cada quadro não quebraria a imagem; apagaria essa prova.

Quem lê a câmera a cada quadro é a prateleira do fundo. Uma malha é a forma de uma peça guardada em listas de números. Um nível de detalhe é uma segunda versão da mesma malha, com menos triângulos, exibida quando o objeto está longe o bastante para a diferença não chegar ao olho. A prateleira liga a versão de perto quando fica a menos de 2,2 m da câmera, sem saber da órbita.

No módulo sobre ativos e orçamento de cena, um botão escrevia direto a posição da câmera perto da prateleira. Mantido na página com a órbita instalada, esse botão funcionaria só até o primeiro arrasto: o arrasto recalcula a posição pelos três números da órbita, que ainda dizem “1,62 m do meio do tampo”, e a câmera salta de volta para a mesa. Por isso, com a órbita instalada, só ela escreve a câmera, como a definição diz.

Este módulo chama cada botão abaixo da cena de botão de enquadramento: cada um pede à órbita o enquadramento de uma parte da cena, a cena inteira, uma peça ou a prateleira. Enquadrar é levar a câmera até o ponto em que aquela parte aparece inteira na tela, e a conta está em Seção 1.4. A distância sai dessa conta, 1,18 m para a prateleira, e o pedido mantém o azimute e a elevação: quem olhava a engrenagem grande por trás e clica no corpo vê o corpo por trás também. Um botão de ângulo fixo desfaria a cada clique o ângulo que a pessoa escolheu.

No diagrama, toda seta que chega à câmera vem da órbita, e a única que sai vai para a prateleira.

%%{init: {"flowchart": {"useMaxWidth": false}, "sequence": {"useMaxWidth": false}, "class": {"useMaxWidth": false}, "state": {"useMaxWidth": false}, "er": {"useMaxWidth": false}}}%%
flowchart LR
    G["Arrasto, roda, pinça"] -->|mudam os ângulos ou a distância| O
    B["Botão de enquadramento"] -->|pede alvo e distância| O
    O["Órbita<br/>alvo e três números"] -->|escreve só quando alguém mexe| C["Câmera"]
    C -->|lida a cada quadro| P["Prateleira<br/>troca o nível de detalhe a 2,2 m"]
Figura 3: A órbita é a única que escreve a câmera; a prateleira só a lê.

A 1,18 m, a prateleira liga o nível de perto. A medição conta 1 chamada de desenho (o pedido que o programa faz à placa de vídeo para desenhar um objeto) e 5.376 triângulos, os números que o botão antigo dava.

os botões de enquadramento, em src/demos/06_demo.ts
// Cada botão de enquadramento pede à órbita um nó da cena, e a distância sai da
// caixa dele, e não de um número escrito aqui. A câmera só se move pela órbita:
// escrever a posição dela direto daqui seria desfeito no primeiro arrasto.
const pedidos: { rotulo: string; no: Object3D }[] = [{ rotulo: 'A cena inteira', no: cena.sala }];
for (const peca of BANCADA.pecas) {
  const no: Object3D | undefined = cena.pecas.get(peca.id);
  if (no !== undefined) {
    pedidos.push({ rotulo: peca.nome, no });
  }
}
pedidos.push({ rotulo: 'A prateleira do fundo', no: prateleira.no });

for (const pedido of pedidos) {
  const botao: HTMLButtonElement = criarNo<HTMLButtonElement>('button', barraDeEnquadramento);
  botao.type = 'button';
  botao.textContent = pedido.rotulo;
  botao.addEventListener('click', () => {
    orbita.enquadrar(caixaDoQueSeVe(pedido.no));
    atualizarFolhas();
  });
}

Nenhuma linha do trecho escreve camera.position: o clique chama orbita.enquadrar(...), e a peça que não está na cena não ganha botão.

Antes de seguir, responda de cabeça. A página está aberta, ninguém mexe na câmera, e cada vez que a folha é reescrita a contagem de escritas da câmera está maior. O que essa contagem diz sobre o código?

1.4 Enquadrar pela esfera, e conferir cada peça

O botão “A cena inteira” precisa de um alvo e de uma distância, e o alvo sai de uma esfera em volta da cena. Um vértice é cada canto dos triângulos que formam a superfície de um objeto. A caixa envolvente do objeto é a menor caixa, com as arestas paralelas a x, y e z, que contém todos os vértices dele. A esfera envolvente tem centro no centro dessa caixa e raio igual à metade da diagonal dela, e por isso contém a caixa inteira. Na cena inteira, o centro, em (0{,}00;\ 0{,}57;\ -1{,}24), vira o alvo, e o raio tem 1,87 m.

A distância depende de quanto a câmera enxerga. O módulo sobre ativos e orçamento de cena chamou de campo de visão a pirâmide de ponta cortada que a câmera enxerga. Daqui em diante, como a área usa, o campo de visão vertical é o ângulo que essa pirâmide abre de cima a baixo: aqui, 55 graus, 27,5 do centro da tela a cada borda. Visto de lado, na distância em que a esfera encosta nas duas bordas, a reta que sai da câmera e raspa o alto da esfera é a borda de cima da tela e faz 27,5 graus com a reta até o centro. O raio até o ponto em que ela raspa faz ângulo reto com ela:

\operatorname{sen} 27{,}5° = \frac{\rho}{d} \qquad\Longrightarrow\qquad d = \frac{\rho}{\operatorname{sen} 27{,}5°}.

A letra \rho é o raio da esfera, e d é a distância da câmera ao centro dela. Na cena inteira, d = 1{,}87 / 0{,}462 \approx 4{,}05 m, e, multiplicada por 1,25, a margem que o projeto escolheu para a cena não grudar nas bordas, a distância vai a 5,06 m (a folha escreve 5,07, com o raio sem arredondar).

NotaDefinição — Enquadramento pela esfera envolvente

Enquadrar um objeto é levar a câmera ao ponto em que ele aparece inteiro na tela. O enquadramento pela esfera envolvente usa a esfera com centro no centro da caixa envolvente do objeto e raio \rho igual à metade da diagonal dela: o centro da esfera vira o alvo, e a distância sai de uma conta.

Com \theta para o campo de visão vertical da câmera, o ângulo que a tela mostra de cima a baixo, a esfera encosta na borda de cima e na de baixo da tela à distância \rho / \operatorname{sen}(\theta/2). A distância pedida é essa, multiplicada por uma margem m maior que 1, para a cena não encostar nas bordas:

d_{\text{pedida}} = \frac{\rho}{\operatorname{sen}(\theta/2)} \times m.

A distância concedida é a pedida presa entre as travas de distância da órbita, e é a que a câmera usa.

A esfera, e não a caixa, porque a esfera ocupa a mesma altura na tela vista de qualquer direção: a conta não precisa ser refeita quando quem olha muda de ângulo. A caixa que entra na conta é a do que aparece na tela; o que está escondido no objeto não entra.

A conta mede a altura da tela, e o objeto aparece inteiro quando a tela é mais larga do que alta, porque aí a altura encosta primeiro. Numa tela mais alta do que larga, quem encosta primeiro é a largura, e a conta passa a valer com o campo de visão horizontal no lugar de \theta.

A engrenagem pequena pede 0,18 m, mas a trava mínima a deixa a 0,35 m: a peça aparece menor do que a conta pediu. Para um objeto achatado como o tampo, a caixa acompanharia melhor a forma. Mas a altura que a caixa ocupa na tela muda com o ângulo de onde se olha: visto de lado, o tampo é uma faixa fina; visto de cima, ocupa muita altura. E, a cada clique num botão, a órbita mantém o ângulo de quem olha. A esfera vale para todos os ângulos de uma vez, ao preço de sobrar espaço em volta do objeto achatado.

1.4.1 A caixa do que aparece

Três casos separam a caixa do que aparece da caixa do objeto inteiro. O volume de contato é a forma simples contra a qual se testa proximidade e toque, maior que a peça. Cada peça do domínio leva pendurado um nó escondido com o desenho em linhas desse volume, e, somado à caixa, esse nó faria a engrenagem pequena pedir 0,26 m em vez de 0,18 m. Um lote é o conjunto de cópias desenhado numa só chamada de desenho, como as 24 peças de cada nível da prateleira. Ele também é um nó de malha, que carrega a malha de uma cópia desenhada 24 vezes em lugares diferentes: tratado como malha, entraria pela caixa dessa cópia só; por isso a conta pergunta antes se é um lote e usa a caixa de todas as cópias. Por fim, a caixa vazia, de um nó sem nada visível, levaria o alvo a um ponto que não pertence a peça nenhuma; o programa a testa antes e não move a câmera.

A caixa do que aparece sai de uma função própria, e não da caixa pronta da biblioteca, Box3.setFromObject, que soma o que está escondido:

a caixa do que aparece, em src/bancada/core/orbita.ts
export function caixaDoQueSeVe(no: Object3D): Box3 {
  const caixa: Box3 = new Box3();
  const parte: Box3 = new Box3();
  no.updateWorldMatrix(true, true);
  // Nó escondido é pulado junto com tudo o que está pendurado nele.
  no.traverseVisible((filho: Object3D) => {
    if (filho instanceof InstancedMesh) {
      // O lote tem caixa própria, que cobre todas as cópias de uma vez.
      filho.computeBoundingBox();
      if (filho.boundingBox !== null) {
        caixa.union(parte.copy(filho.boundingBox).applyMatrix4(filho.matrixWorld));
      }
      return;
    }
    if (filho instanceof Mesh) {
      if (filho.geometry.boundingBox === null) {
        filho.geometry.computeBoundingBox();
      }
      if (filho.geometry.boundingBox !== null) {
        caixa.union(parte.copy(filho.geometry.boundingBox).applyMatrix4(filho.matrixWorld));
      }
    }
  });
  return caixa;
}
a conta do enquadramento, em src/bancada/core/orbita.ts
export function planejarEnquadramento(
  caixa: Box3,
  campoVerticalGraus: number,
): Enquadramento | undefined {
  // Caixa vazia não tem centro: a biblioteca devolveria uma esfera de raio -1.
  if (caixa.isEmpty()) {
    return undefined;
  }
  const esfera: Sphere = caixa.getBoundingSphere(new Sphere());
  const meioCampo: number = MathUtils.degToRad(campoVerticalGraus / 2);

  // A esfera encosta na borda de cima e na de baixo da tela quando a distância
  // até o centro dela é o raio dividido pelo seno da metade do campo de visão.
  const pedida: number = (esfera.radius / Math.sin(meioCampo)) * MARGEM_DE_ENQUADRAMENTO;
  const concedida: number = MathUtils.clamp(
    pedida,
    TRAVAS_DA_ORBITA.raioMinimoM,
    TRAVAS_DA_ORBITA.raioMaximoM,
  );

  // Vista da câmera, a esfera se abre num ângulo cujo seno é raio sobre
  // distância; na tela, conta a tangente desse ângulo contra a do meio campo.
  // A conta mede só a altura da tela, e vale porque a tela de desenho da página
  // tem proporção 16 por 9: mais larga do que alta, é a altura que encosta
  // primeiro. Numa tela mais alta do que larga, a esfera sairia pelos lados com
  // a fração ainda abaixo de 1.
  const angulo: number = Math.asin(Math.min(1, esfera.radius / concedida));
  return {
    centro: esfera.center.clone(),
    raioDaEsferaM: esfera.radius,
    distanciaPedidaM: pedida,
    distanciaConcedidaM: concedida,
    fracaoDaAltura: Math.tan(angulo) / Math.tan(meioCampo),
  };
}

traverseVisible pula o escondido, InstancedMesh vem antes de Mesh, e caixa.isEmpty() faz a conta devolver undefined. no.updateWorldMatrix(true, true) trata um quarto caso: antes do primeiro quadro, a biblioteca ainda não refez a cópia da transformação no mundo que ela guarda, e sem essa linha a caixa de uma peça presa ao tampo sairia sem a altura dele, 0,90 m abaixo do lugar certo.

1.4.2 Quanto da tela cada peça ocupa

Um ambiente em janela só está pronto se qualquer pessoa consegue trazer cada peça do domínio para a tela, inteira. Ao abrir, a página passa as cinco peças pela conta do enquadramento e escreve o resultado na folha; o projeto chama essa passagem de conferência da entrega:

Peça Distância pedida Distância concedida Altura da tela que a peça ocupa
corpo 0,24 m 0,35 m 51%
eixo 0,30 m 0,35 m 65%
engrenagem grande 0,30 m 0,35 m 64%
engrenagem pequena 0,18 m 0,35 m 37%
tampa do mecanismo 0,25 m 0,35 m 52%

Vista da câmera, a esfera se abre num ângulo cujo seno é \rho/d; na tela, porém, a altura cresce com a tangente desse ângulo, a borda da tela fica na tangente de \theta/2, e a fração da altura da tela que a esfera ocupa é:

\text{fração} = \frac{\tan\!\big(\operatorname{arcsen}(\rho/d)\big)}{\tan(\theta/2)}.

Fração 1 quer dizer que a esfera encosta nas duas bordas; acima de 1, ela não cabe. Na cena inteira, com a margem, a fração é \tan 21{,}7° / \tan 27{,}5° \approx 0{,}76; a engrenagem pequena, a 0,35 m, fica em 0,37. Numa tela mais alta do que larga, a esfera cabe só se a fração não passar da razão entre a largura e a altura da tela. Com essa razão em 0,5, só a engrenagem pequena caberia, e a contagem, feita só pela altura, continuaria dizendo 5 de 5.

A fração responde à pergunta “dá para trazer esta peça para a tela?”. A pergunta vizinha, que não se confunde com essa, é a do alcance de braço, a distância até onde a mão chega sem dar um passo (Seção 1.6).

NotaDefinição — Peça coberta pela órbita

Uma peça do domínio está coberta pela órbita quando a órbita consegue enquadrá-la: a peça existe na cena, e a esfera envolvente dela cabe inteira na altura da tela à distância concedida. A medida é a fração da altura, quanto da altura da tela o diâmetro da esfera ocupa nessa distância, e a peça está coberta quando a fração é no máximo 1. A cobertura do domínio é a conta de quantas peças do domínio estão cobertas.

A conferência usa a mesma conta que a órbita usa para se mover. Uma conferência com conta própria poderia aprovar uma peça que a câmera não consegue mostrar.

A cobertura é coisa da câmera e da tela, e não do corpo. A peça coberta pela órbita pode estar a qualquer distância, e chegar a ela custa o mesmo gesto; é por isso que o regime em janela cobre todas as peças e não tem alcance de braço.

Nesta cena, a cobertura do domínio é de 5 de 5. O diagrama junta o enquadramento e a conferência: a distância concedida que move a câmera é a que entra na fração.

%%{init: {"flowchart": {"useMaxWidth": false}, "sequence": {"useMaxWidth": false}, "class": {"useMaxWidth": false}, "state": {"useMaxWidth": false}, "er": {"useMaxWidth": false}}}%%
flowchart TD
    CX["Caixa do que aparece"] -->|vazia| X["Sem enquadramento:<br/>a câmera não se move<br/>e a peça não conta como coberta"]
    CX --> ES["Esfera envolvente<br/>o centro vira o alvo"]
    ES --> PE["Distância pedida<br/>raio ÷ seno da metade do campo × margem"]
    PE --> CO["Distância concedida<br/>a pedida presa entre 0,35 m e 8 m"]
    CO --> FR["Fração da altura da tela"]
    FR --> Q{"Fração até 1?"}
    Q -->|sim| S["Peça coberta"]
    Q -->|não| N["Peça não coberta"]
Figura 4: Da caixa do que aparece à peça coberta, pela mesma conta que move a câmera.

Por que a fração, e não a distância? Uma peça com esfera de 3,3 m de raio pede 8,93 m, fica a 8 m e ocupa ali 87% da altura: uma regra pela distância pedida a reprovaria, e a da fração a aprova. E por que a esfera, e não a caixa vista de frente? Uma barra de 10 m por 20 cm, com esfera de 5 m de raio, fica a 8 m, presa pela trava, e ali ocupa 2% da altura vista de frente. Vista a 85 graus do lado de uma ponta, ela sai pelas bordas, e pela esfera a fração é 1,54: a barra não passa. E uma peça renomeada na cena e não na lista do domínio derruba a contagem para 4 de 5 sem dizer nada sobre a órbita.

A órbita chega à engrenagem pequena, a 0,35 m, e à cena inteira, a 5,07 m, com o mesmo gesto, e é essa a razão de não haver alcance de braço, e não uma versão melhor dele: nada na tela diz o que está perto do corpo de quem olha. E o programa não tem como saber se a mão chega à peça (Seção 1.6.3).

a conferência da entrega, em src/bancada/modes/janela.ts
export function medirEntrega(
  raiz: Object3D,
  pecas: readonly Peca[],
  camera: PerspectiveCamera,
): EntregaDaJanela {
  const coberturas: CoberturaDaPeca[] = [];
  let cobertas: number = 0;
  for (const peca of pecas) {
    const no: Object3D | undefined = raiz.getObjectByName(peca.id);
    const plano: Enquadramento | undefined =
      no === undefined ? undefined : planejarEnquadramento(caixaDoQueSeVe(no), camera.fov);
    const coberta: boolean = plano !== undefined && plano.fracaoDaAltura <= 1;
    if (coberta) {
      cobertas += 1;
    }
    coberturas.push({ peca, enquadramento: plano, coberta });
  }

  return {
    regime: regimePorId('inline'),
    cena: planejarEnquadramento(caixaDoQueSeVe(raiz), camera.fov),
    pecas: coberturas,
    cobertas,
  };
}

Cada peça é procurada pelo nome, raiz.getObjectByName(peca.id), e passa por planejarEnquadramento, a conta da órbita; coberta é plano.fracaoDaAltura <= 1.

1.5 Uma página que outra pessoa abre, dentro do teto

A página precisa abrir em outro aparelho, ensinar o próprio uso e caber no tempo de cada quadro.

A janela também só está pronta se outra pessoa a abre, num aparelho que não é o de quem a construiu, sem ninguém explicar nada. O computador em que o ambiente é construído o serve aos aparelhos da mesma rede, num endereço feito do IP dele e da porta em que atende. No celular de outra pessoa, a página é tudo o que ela tem, e o primeiro gesto que ela tentar é o que imagina que funciona.

Por isso a página escreve, antes da cena, o que este módulo chama de explicação de uso: arrastar gira em volta da cena, a roda ou dois dedos levam a câmera para perto e para longe, os botões levam a câmera até cada peça. Antes da cena, a explicação é lida antes de a pessoa tentar; depois da cena, só depois de a pessoa desistir, se for lida. Os botões, com o nome de cada peça, completam a explicação para quem não descobriu como girar. Se um gesto mudar, nada no programa acusa a frase que ficou errada; por isso ela fica guardada junto dos gestos que ensina, à vista de quem os muda.

a explicação de uso, em src/bancada/core/orbita.ts
/** O que a página diz a quem chega, escrito ao lado do código que atende cada gesto. */
export const INSTRUCAO_DA_ORBITA: string =
  'Arraste sobre a cena para girar em volta dela. Use a roda do mouse, ou afaste e ' +
  'aproxime dois dedos na tela, para chegar perto e para se afastar. Os botões abaixo ' +
  'da cena levam a câmera até cada peça.';

O servidor de desenvolvimento do projeto atende na rede, por https, na porta 5173. Na primeira vez, o navegador avisa sobre o certificado, a identificação que ele confere antes de confiar na conexão: o servidor gera o próprio certificado, e nenhuma autoridade que o navegador conhece o assinou. Aceitar é seguro só porque o endereço é o do seu computador, na sua rede; no Chrome em português, o caminho é Avançado e, depois, Ir para o endereço (não seguro). Num endereço que você não reconhece, o mesmo aviso é motivo para parar.

1.5.1 A folga sai do custo

O módulo do grafo de cena separou dois tempos. O custo de um quadro é o tempo que o trabalho do próprio programa consome no processador entre uma imagem e a seguinte. O intervalo é o tempo real entre duas imagens apresentadas: nele entram o custo e tudo o mais, o desenho na placa de vídeo e o que os outros programas da máquina fazem. O módulo sobre ativos e orçamento de cena acrescentou o teto, o tempo que o programa tem para preparar um quadro, escrito no código como número. Esta página mede contra o teto da tela, 16,7 ms (1.000 / 60), e não contra o do visor, 11,1 ms.

A tela troca de imagem a cada 16,7 ms, e não antes, e o intervalo fica perto de 16,7 ms com 0,3 ms ou com 3 ms de trabalho por quadro. Por isso, a sobra tirada do intervalo dá perto de zero nos dois casos, e a tirada do custo dá 16,4 ms e 13,7 ms.

NotaDefinição — Folga

A folga é o que sobra do teto depois do custo: o teto do quadro menos o tempo médio que o trabalho do próprio programa leva por quadro, em milissegundos. Ela diz quanto trabalho ainda cabe no quadro.

A folga sai do custo, e não do intervalo entre imagens. O intervalo acompanha o ritmo da tela e não desce abaixo dele, por mais leve que a cena seja: numa tela de sessenta imagens por segundo, com o teto de 16,7 ms, a folga tirada do intervalo fica perto de zero em qualquer cena, leve ou pesada.

O intervalo responde a outra pergunta: se alguma imagem foi perdida. Ele anda em múltiplos do ritmo da tela, e um intervalo perto do dobro do ritmo é uma imagem que não saiu a tempo.

A folha do módulo sobre ativos continua tirando a sobra do intervalo médio; a folha nova, a do regime em janela, tira a folga do custo.

Uma imagem que não fica pronta a tempo faz a tela repetir a anterior, e o intervalo vai a 2 \times 1.000/60 \approx 33{,}3 ms: é uma imagem perdida. Numa execução fora da máquina de referência (o aparelho contra o qual a cena é orçada), o pior intervalo (o mais longo dos últimos 120 quadros) deu 16,8 ms, um décimo acima do teto. Nenhuma imagem se perdeu: é o tremor da medida. Por isso o programa só acusa imagem perdida acima de uma vez e meia o teto, 25,05 ms.

Com imagem perdida, a folga diz onde procurar primeiro. Custo médio acima do teto é o programa que não coube. Dentro do teto, o atraso pode ter vindo da placa de vídeo, do compositor (a parte do sistema operacional que junta na tela o que todos os programas desenham) ou de outro programa. Pode, mas nada garante: um quadro em que o código leva 30 ms, entre 119 de 0,24 ms, perde a imagem e deixa a média em 0,49 ms, dentro do teto. O medidor é a parte do programa que compara o tempo de cada quadro com o teto e guarda os números dos últimos 120 quadros, e o diagrama separa o que cada número dele responde:

%%{init: {"flowchart": {"useMaxWidth": false}, "sequence": {"useMaxWidth": false}, "class": {"useMaxWidth": false}, "state": {"useMaxWidth": false}, "er": {"useMaxWidth": false}}}%%
flowchart LR
    M["Medidor<br/>últimos 120 quadros"] --> CM["Custo médio"]
    M --> PI["Pior intervalo"]
    CM --> F["Folga<br/>teto menos custo médio"]
    PI --> Q{"Acima de 1,5 vez o teto<br/>25,05 ms?"}
    Q -->|sim| IP["Imagem perdida acusada"]
    Q -->|não| OK["Nenhuma imagem perdida acusada"]
Figura 5: Do medidor saem dois números, e cada um responde a uma pergunta.

Os primeiros quadros fazem trabalho que só se faz uma vez. Uma medida tirada com um ou dois deles sai mais de cem vezes acima da que sai depois; por isso as folhas medem só no clique em “Medir agora”, e a medida que vale é a de 120 quadros. Nenhum custo deste módulo vale como número do laboratório: a máquina de referência ainda não foi medida.

o custo contra o teto, em src/bancada/modes/janela.ts
export function custoContraOTeto(medida: LeituraDoOrcamento): string[] {
  if (medida.quadrosMedidos === 0) {
    return ['Custo do quadro: ainda sem quadros medidos.'];
  }
  const folga: number = medida.tetoMs - medida.custoMedioMs;
  const veredito: string =
    folga >= 0
      ? `dentro do teto de ${medida.tetoMs.toFixed(1)} ms, com ${folga.toFixed(1)} ms de folga`
      : `acima do teto de ${medida.tetoMs.toFixed(1)} ms em ${(-folga).toFixed(1)} ms`;
  const linhas: string[] = [
    `Trabalho do código por quadro: ${medida.custoMedioMs.toFixed(2)} ms em média, ` +
      `em ${medida.quadrosMedidos} ${medida.quadrosMedidos === 1 ? 'quadro' : 'quadros'}, ${veredito}`,
    `Intervalo entre imagens: ${medida.intervaloMedioMs.toFixed(1)} ms em média, ` +
      `o pior ${medida.piorIntervaloMs.toFixed(1)} ms`,
  ];
  if (medida.piorIntervaloMs > medida.tetoMs * FATOR_DE_IMAGEM_PERDIDA) {
    linhas.push(
      folga >= 0
        ? 'Houve imagem perdida com o custo médio dentro do teto: a média não mostra o quadro ' +
            'perdido, e o atraso pode ter vindo de um quadro em que o código passou do teto ou de ' +
            'fora do código medido, da placa de vídeo, do compositor do sistema ou de outro programa'
        : 'Houve imagem perdida, e o trabalho do código sozinho já passa do teto',
    );
  }
  return linhas;
}

medida vem do medidor, num tipo que o código do módulo do grafo de cena chamou de LeituraDoOrcamento, sem o sentido de leitura do módulo sobre ativos, a conta do que a placa desenhou. O teste de imagem perdida não depende do sinal da folga, e a frase que ele escreve depende. Com a folga negativa, ela diz que o código sozinho passa do teto; positiva, dá os dois lugares onde procurar.

1.6 O que a tela não dá, aferido com o programa rodando

A escala corporal é saber, sem régua, que um tampo está na altura da cintura.

NotaDefinição — Limite de um regime

Um limite de um regime é uma coisa que o regime não oferece, escrita em quatro partes: um nome que o identifica; o enunciado do que falta, numa frase que quem lê consegue conferir; a razão técnica de faltar; e o que a falta impede de demonstrar naquele regime.

A última parte é a que separa o limite de uma lamentação. “Não há escala corporal” diz o que falta; “não dá para mostrar que o tampo tem a altura certa para trabalhar de pé” diz o que deixa de ser demonstrável, e é isso que precisa saber quem compara dois regimes.

Os limites ficam escritos como dado, junto do ambiente que eles descrevem: quem abre o ambiente os encontra na mesma página.

O regime em janela tem três limites: a profundidade é inferida, não há escala corporal, não há alcance de braço.

1.6.1 A profundidade que se calcula

Ponha as duas engrenagens lado a lado no tampo, vistas de frente na tela. Qual está mais perto de você? A imagem dá pistas. As linhas da mesa, paralelas na mesa de verdade, se aproximam quanto mais longe vão: é a perspectiva. A engrenagem que tampa um pedaço da outra está na frente: é a oclusão. E, se você sabe o tamanho das duas, a que parece menor do que deveria está mais longe. A pista do tamanho e a das linhas vêm da projeção em perspectiva, de Computação Gráfica, que divide a posição de cada ponto na tela pela distância até a câmera; numa câmera ortográfica, que não divide, só sobraria a oclusão.

Com as engrenagens perto uma da outra e nenhuma tampando a outra, as pistas quase não dizem nada, e o jeito, na janela, é girar a câmera e ver de lado. A distância calculada a partir das pistas da própria imagem é a profundidade inferida. A pista que falta à janela é a diferença entre duas imagens. Em 1838, Charles Wheatstone mostrou à Royal Society, em Londres, um aparelho de dois espelhos que levava a cada olho uma de duas figuras quase idênticas, e quem olhava via um sólido que não estava em nenhuma das duas.

NotaDefinição — Profundidade inferida

A profundidade inferida é a distância que quem olha calcula a partir de pistas da própria imagem: a perspectiva, a oclusão (o objeto da frente tampando o de trás) e o tamanho relativo dos objetos.

Ela se opõe à profundidade vista com os dois olhos. Num visor, cada olho recebe uma imagem um pouco diferente, e a diferença entre as duas é uma pista que uma imagem sozinha não tem. Uma tela plana mostra a mesma imagem aos dois olhos, e por isso, no regime em janela, toda profundidade é inferida.

Profundidade inferida não é profundidade ausente: a cena na tela tem perto e longe. O que ela não sustenta é julgar, sem girar a câmera, qual de duas peças próximas está mais perto, que é o julgamento que encaixar uma peça na outra pede.

1.6.2 O corpo que a tela não conhece

O tampo está a 90 cm do chão da cena, na tela de um celular ou projetado na parede, mas em nenhum dos dois casos você diz, olhando, que ele está na altura da sua cintura. O cursor chega à engrenagem pequena e à prateleira com o mesmo movimento: nenhuma peça fica “longe demais para pegar”, porque o cursor não tem braço.

NotaDefinição — Escala corporal e alcance de braço

A escala corporal é a comparação entre o tamanho das coisas da cena e o corpo de quem as vê: saber, sem régua, que um tampo está na altura da cintura. O alcance de braço é a distância até onde a mão chega sem dar um passo.

As duas são medidas contra o corpo, e só existem num regime que sabe onde o corpo está: onde fica o chão da sala, a que altura estão os olhos, onde está a mão. O regime em janela não sabe nenhuma dessas coisas. A cena aparece do tamanho que a tela tiver, e o cursor chega a qualquer ponto da tela com o mesmo gesto, esteja a peça perto ou longe da câmera.

Nenhuma das duas falta por defeito de implementação, e nenhum código dentro do regime em janela as conserta: elas chegam com o regime que conhece o chão e rastreia a mão.

Prometer “a escala corporal numa versão futura da janela” seria errado: a janela precisaria saber a altura dos seus olhos e onde fica o seu chão, e nenhum gesto na tela informa isso. Os três limites, com as quatro partes de cada um:

Nome O que falta Por que falta O que impede demonstrar
profundidade inferida a profundidade vista com os dois olhos a tela mostra a mesma imagem aos dois olhos julgar, sem girar a câmera, qual de duas peças próximas está mais perto
sem escala corporal a comparação da cena com o corpo o ambiente não sabe onde fica o chão da sala nem a altura dos olhos de quem olha que o tampo tem a altura certa para trabalhar de pé
sem alcance de braço a distância até onde a mão chega o cursor chega a qualquer ponto da tela com o mesmo gesto, de 0,35 m a 8 m da câmera que uma peça está longe demais para ser apanhada sem dar um passo até ela
as quatro partes de um limite, em src/bancada/modes/janela.ts
export interface LimiteDoRegime {
  readonly id: 'profundidade-inferida' | 'sem-escala-corporal' | 'sem-alcance-de-braco';
  /** O que falta, numa frase que quem lê consegue conferir. */
  readonly enunciado: string;
  /** Por que falta: a razão técnica. */
  readonly porque: string;
  /** O que a falta impede de demonstrar neste regime. */
  readonly impedeDemonstrar: string;
}
os três limites da janela, em src/bancada/modes/janela.ts
export const LIMITES_DA_JANELA: readonly LimiteDoRegime[] = [
  {
    id: 'profundidade-inferida',
    enunciado: 'A profundidade é inferida da imagem, e não vista com os dois olhos.',
    porque:
      'a tela mostra a mesma imagem aos dois olhos; a distância se infere da perspectiva, ' +
      'de qual peça tampa qual e do tamanho relativo, e não da diferença entre duas vistas',
    impedeDemonstrar:
      'julgar, sem girar a câmera, qual de duas peças próximas está mais perto, que é o ' +
      'julgamento que encaixar uma peça na outra pede',
  },
  {
    id: 'sem-escala-corporal',
    enunciado: 'Não há escala corporal.',
    porque:
      'o tampo está a 90 cm do chão da cena, mas o ambiente não sabe onde fica o chão da sala ' +
      'nem a altura dos olhos de quem olha; a bancada aparece do tamanho que a janela tiver',
    impedeDemonstrar: 'que a bancada tem a altura certa para trabalhar de pé diante dela',
  },
  {
    id: 'sem-alcance-de-braco',
    enunciado: 'Não há alcance de braço.',
    porque:
      `o cursor chega a qualquer ponto da tela com o mesmo gesto, esteja a peça a ` +
      `${TRAVAS_DA_ORBITA.raioMinimoM.toFixed(2)} m ou a ${TRAVAS_DA_ORBITA.raioMaximoM} m da câmera`,
    impedeDemonstrar: 'que uma peça está longe demais para ser apanhada sem dar um passo até ela',
  },
];

Os quatro campos são obrigatórios, e um limite sem impedeDemonstrar não compila. Nas frases do código, bancada é a mesa da cena, e os números da terceira entrada vêm de TRAVAS_DA_ORBITA: mudada uma trava, a frase muda junto.

1.6.3 Cada limite aferido

Uma lista de limites escrita uma vez continua dizendo o mesmo se o regime mudar. No computador de mesa e sem visor, confira o que o programa em execução sabe:

  1. Profundidade inferida. O programa sabe se a biblioteca entrega uma imagem por quadro, para a tela, ou uma para cada olho, para um visor. Com uma por quadro, a mesma imagem vai aos dois olhos, e o limite vale.
  2. Sem escala corporal. O espaço de referência de um regime é o sistema de coordenadas das posições do ambiente, com a regra que fixa a origem. O do regime em janela, declarado no módulo sobre o contínuo, acompanha o olhar e não tem chão, e o programa sabe também que nenhum visor está apresentando imagem. Sem chão e sem visor, o limite vale.
  3. Sem alcance de braço. Nada no programa mede o braço de quem usa: o cursor não tem corpo, e o resultado não pode ser “vale” nem “não vale”.
NotaDefinição — Aferição de um limite

A aferição de um limite é o confronto do que o limite afirma com o que o programa em execução sabe. Ela termina em um de três resultados: confirmado, quando o programa sabe o bastante para dizer que o limite vale; contrariado, quando sabe o bastante para dizer que não vale; e inconclusivo, quando o programa não tem como saber.

O terceiro resultado não é falha da aferição. Sem ele, um limite que nada no programa mede teria de sair confirmado ou contrariado sem fundamento, com a mesma cara dos que foram medidos; inconclusivo diz a quem lê que aquela linha depende de uma pessoa.

A aferição descreve o regime em uso. Os limites da profundidade e da escala corporal, aferidos com um visor apresentando a imagem, saem contrariados.

No computador de mesa, a aferição dá confirmado, confirmado e inconclusivo. O diagrama mostra as três saídas de uma aferição.

%%{init: {"flowchart": {"useMaxWidth": false}, "sequence": {"useMaxWidth": false}, "class": {"useMaxWidth": false}, "state": {"useMaxWidth": false}, "er": {"useMaxWidth": false}}}%%
flowchart TD
    L["Um limite da lista"] --> Q{"O que o programa<br/>em execução sabe?"}
    Q -->|o bastante para dizer que vale| C["confirmado"]
    Q -->|o bastante para dizer que não vale| X["contrariado"]
    Q -->|não tem como saber| I["inconclusivo"]
Figura 6: A aferição de um limite termina numa de três saídas.

Um indicador que desse confirmado para o alcance de braço deixaria essa linha com a mesma cara das linhas medidas, sem nada ter sido medido, e a cobertura de 5 de 5 também não o confirma. Sem visor, outra combinação diz que a declaração do regime, ou o código da aferição, mudou.

Por isso os limites ficam na página, abaixo da cena, com a aferição de cada um e com a frase com que o módulo sobre o contínuo declarou o regime imersivo: “É onde escala corporal e alcance de braço passam a existir — e nenhum dos dois tem equivalente na janela do desktop.”

a aferição dos três limites, em src/bancada/modes/janela.ts
export function aferirLimites(renderer: WebGLRenderer): LimiteAferido[] {
  // Verdadeiro só quando o renderizador entrega imagem a um visor, uma para cada olho.
  const emVisor: boolean = renderer.xr.isPresenting;
  const declarado: Regime = regimePorId('inline');

  return LIMITES_DA_JANELA.map((limite: LimiteDoRegime): LimiteAferido => {
    switch (limite.id) {
      case 'profundidade-inferida':
        return {
          limite,
          afericao: emVisor ? 'contrariado' : 'confirmado',
          observacao: emVisor
            ? 'o renderizador está entregando uma imagem para cada olho'
            : 'o renderizador desenha uma imagem por quadro, a da câmera da página',
        };
      case 'sem-escala-corporal':
        return {
          limite,
          afericao: !emVisor && declarado.espacoDeReferencia === 'viewer' ? 'confirmado' : 'contrariado',
          observacao:
            `o regime foi declarado com o espaço de referência '${declarado.espacoDeReferencia}', ` +
            'que acompanha o olhar e não tem chão' +
            (emVisor ? ', mas há um visor apresentando, e com ele um chão' : ', e nenhum visor está apresentando'),
        };
      case 'sem-alcance-de-braco':
        return {
          limite,
          afericao: 'inconclusivo',
          observacao: 'nada no programa mede o braço de quem usa: o cursor não tem corpo',
        };
    }
  });
}
a folha do regime na página, em src/bancada/relatorio/janela.ts
export function montarRegimeEmJanela(
  raiz: HTMLElement,
  orbita: readonly string[],
  regime: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('A câmera em órbita'));
  for (const linha of orbita) {
    raiz.appendChild(paragrafo(linha));
  }

  raiz.appendChild(subtitulo('O que a janela entrega, e o que ela não oferece'));
  const bloco: HTMLPreElement = document.createElement('pre');
  bloco.textContent = regime.join('\n');
  raiz.appendChild(bloco);
}

renderer.xr.isPresenting diz se o renderizador, a parte da biblioteca que desenha a cena, está entregando imagem a um visor. O switch sem caso padrão faz a compilação acusar um quarto limite sem resultado.

1.7 A Bancada inteira, girando na tela

Na Bancada, a oficina de montagem que o projeto constrói, este é o estado em que a cena deixa de ser vista de um lado só. O arquivo abaixo compõe a página deste estado: monta a cena do estado anterior inteira, com a morsa, os parafusos e a prateleira, põe a órbita sobre a câmera, cria os botões de enquadramento e escreve as duas folhas.

a composição deste estado, em src/demos/06_demo.ts
// ---------------------------------------------------------------------------
// Marco 06 — a cena inteira, percorrida pela tela.
//
// A cena é a mesma do marco anterior, inteira: as peças vestidas, a borda
// parafusada, a prateleira em dois níveis e a morsa importada. O que muda é
// quem manda na câmera. A órbita passa a ser a única que escreve a posição dela,
// e por isso o botão que levava a câmera até a prateleira virou um pedido à
// órbita, junto com um botão por peça do domínio e um para a cena inteira.
//
// A folha do regime confere que cada peça do domínio está coberta pela órbita e
// escreve os três limites deste regime, com a aferição de cada um. A folha de
// ativos e de custo continua a do marco anterior, com a medição no botão.
//
// Ponto de entrada do MARCO 06, imutável.
// ---------------------------------------------------------------------------

import { Vector3, type BufferGeometry, type InstancedMesh, type Mesh, type Object3D } from 'three';

import { montarPalco, type Palco } from '../bancada/core/palco';
import { montarCena, type CenaDaBancada } from '../bancada/core/cena';
import { Relogio, type Amostra } from '../bancada/core/relogio';
import { Orcamento, TETO_DESKTOP_MS } from '../bancada/core/orcamento';
import { montarLaco, type Laco } from '../bancada/core/laco';
import { INSTRUCAO_DA_ORBITA, caixaDoQueSeVe, montarOrbita, type Orbita } from '../bancada/core/orbita';
import { vestirCena, type Conteudo } from '../bancada/content/pecas';
import { importarAtivo, linhasDoAtivo, type AtivoImportado } from '../bancada/content/ativos';
import { censoDaCena, identificarPlaca, linhasDaMedicao } from '../bancada/content/medicao';
import { extrudarPerfil, perfilRegular } from '../bancada/content/malha';
import {
  ESPACAMENTO_DOS_PARAFUSOS,
  custoDaRepeticao,
  disporNaBorda,
  linhasDaRepeticao,
  montarLoteInstanciado,
  montarPrateleira,
  type CustoDaRepeticao,
  type Prateleira,
} from '../bancada/content/repeticao';
import { BANCADA } from '../bancada/dominio/dominio';
import { aferirLimites, custoContraOTeto, linhasDaJanela, medirEntrega } from '../bancada/modes/janela';
import { paragrafo } from '../bancada/relatorio/capacidades';
import { montarAtivosECusto } from '../bancada/relatorio/custo';
import { montarRegimeEmJanela } from '../bancada/relatorio/janela';

// A página roda na tela do computador e mede contra o teto dela, 16,7 ms.
const TETO_DO_QUADRO_MS: number = TETO_DESKTOP_MS;

// A morsa, os parafusos e a prateleira entram como no marco anterior, com os
// mesmos números.
const MAIOR_LADO_DA_MORSA_M: number = 0.18;
const POSICAO_DA_MORSA: Vector3 = new Vector3(-0.52, 0.025, 0.0);
const PECAS_DE_REPOSICAO: number = 24;
const REMOCAO_NO_NIVEL_DISTANTE: number = 0.5;

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

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

const titulo: HTMLHeadingElement = criarNo<HTMLHeadingElement>('h1', raiz);
titulo.textContent = 'Bancada — a cena inteira, percorrida pela tela';

// A instrução vem antes da cena: quem abre o endereço lê o que fazer antes de
// tentar, e não depois de desistir.
raiz.appendChild(paragrafo(INSTRUCAO_DA_ORBITA));

const canvas: HTMLCanvasElement = criarNo<HTMLCanvasElement>('canvas', raiz, 'cena');
const barraDeEnquadramento: HTMLElement = criarNo<HTMLElement>('div', raiz, 'enquadramento');
const botaoMedir: HTMLButtonElement = criarNo<HTMLButtonElement>('button', raiz, 'medir');
botaoMedir.type = 'button';
botaoMedir.textContent = 'Medir agora';

const raizJanela: HTMLElement = criarNo<HTMLElement>('div', raiz, 'janela');
const raizCusto: HTMLElement = criarNo<HTMLElement>('div', raiz, 'custo');

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

const conteudo: Conteudo = vestirCena(cena);

const tampo: Object3D | undefined = cena.sala.getObjectByName('tampo');
if (tampo === undefined) {
  throw new Error('A cena não trouxe o tampo, e a borda parafusada se prende a ele.');
}

const posicoesDosParafusos: Vector3[] = disporNaBorda(1.4, 0.8, ESPACAMENTO_DOS_PARAFUSOS, 0.02, 0.026);
const geometriaDoParafuso: BufferGeometry = extrudarPerfil(perfilRegular(6, 0.004), 0.003, 'parafuso');
const parafusos: InstancedMesh = montarLoteInstanciado(
  geometriaDoParafuso,
  conteudo.catalogo.acoEscovado.material,
  posicoesDosParafusos,
  'parafusos-da-borda',
);
tampo.add(parafusos);
const custoDosParafusos: CustoDaRepeticao = custoDaRepeticao(
  geometriaDoParafuso,
  posicoesDosParafusos.length,
);

const engrenagem: Object3D | undefined = cena.pecas.get('engrenagem-grande');
if (engrenagem === undefined) {
  throw new Error('A cena não trouxe a engrenagem grande, e a prateleira copia a malha dela.');
}
const prateleira: Prateleira = montarPrateleira(
  (engrenagem as Mesh).geometry,
  conteudo.catalogo.latao.material,
  PECAS_DE_REPOSICAO,
  REMOCAO_NO_NIVEL_DISTANTE,
);
prateleira.no.position.set(0, 0.4, -2.8);
cena.sala.add(prateleira.no);

// A órbita entra depois de o palco pôr a câmera no lugar, e parte de onde ela
// está.
const orbita: Orbita = montarOrbita(palco.camera, canvas);

let quadros: number = 0;
let laudoDoAtivo: string[] = ['O ativo externo ainda não foi trazido.'];
let medicao: string[] = ['A medição sai no clique em "Medir agora".'];
let custo: string[] = ['O custo do quadro sai no clique em "Medir agora".'];

function atualizarFolhas(): void {
  montarRegimeEmJanela(
    raizJanela,
    [...orbita.descrever(), `Quadros desenhados desde que a página abriu: ${quadros}`],
    [
      ...linhasDaJanela(medirEntrega(cena.sala, BANCADA.pecas, palco.camera), aferirLimites(palco.renderer)),
      '',
      ...custo,
    ],
  );
  montarAtivosECusto(
    raizCusto,
    laudoDoAtivo,
    linhasDaRepeticao(custoDosParafusos),
    prateleira.linhas(),
    medicao,
  );
}

// recorte:inicio destinos-pedidos-a-orbita
// Cada botão de enquadramento pede à órbita um nó da cena, e a distância sai da
// caixa dele, e não de um número escrito aqui. A câmera só se move pela órbita:
// escrever a posição dela direto daqui seria desfeito no primeiro arrasto.
const pedidos: { rotulo: string; no: Object3D }[] = [{ rotulo: 'A cena inteira', no: cena.sala }];
for (const peca of BANCADA.pecas) {
  const no: Object3D | undefined = cena.pecas.get(peca.id);
  if (no !== undefined) {
    pedidos.push({ rotulo: peca.nome, no });
  }
}
pedidos.push({ rotulo: 'A prateleira do fundo', no: prateleira.no });

for (const pedido of pedidos) {
  const botao: HTMLButtonElement = criarNo<HTMLButtonElement>('button', barraDeEnquadramento);
  botao.type = 'button';
  botao.textContent = pedido.rotulo;
  botao.addEventListener('click', () => {
    orbita.enquadrar(caixaDoQueSeVe(pedido.no));
    atualizarFolhas();
  });
}
// recorte:fim destinos-pedidos-a-orbita

atualizarFolhas();

// As duas medidas saem no clique, e não na abertura: os primeiros quadros
// carregam o trabalho que só se faz uma vez, e o medidor ainda não juntou os
// 120 quadros que guarda.
botaoMedir.addEventListener('click', () => {
  medicao = linhasDaMedicao(censoDaCena(cena.sala), orcamento.ler(), identificarPlaca(palco.renderer));
  custo = custoContraOTeto(orcamento.ler());
  atualizarFolhas();
});

let ativo: AtivoImportado | undefined = undefined;

void importarAtivo('/ativos/morsa.gltf', {
  maiorLadoRealM: MAIOR_LADO_DA_MORSA_M,
  giroEmXGraus: -90,
  assentarNaBase: true,
  centrarNoPlano: true,
})
  .then((importado: AtivoImportado) => {
    ativo = importado;
    importado.no.position.add(POSICAO_DA_MORSA);
    tampo.add(importado.no);
    laudoDoAtivo = linhasDoAtivo(importado);
    atualizarFolhas();
  })
  .catch((erro: unknown) => {
    laudoDoAtivo = [
      'O ativo externo não pôde ser trazido.',
      erro instanceof Error ? erro.message : String(erro),
    ];
    atualizarFolhas();
  });

// O laço não chama a órbita: a câmera só é escrita quando alguém mexe. Quem lê a
// câmera a cada quadro é a prateleira, para escolher o nível.
laco.aoPasso((amostra: Amostra) => {
  quadros += 1;
  cena.eixo.rotation.y += amostra.delta * 0.6;
  ativo?.avancar(amostra.delta);
  prateleira.atualizar(palco.camera);
});

laco.iniciar();

O comentário do topo chama a página de Marco 06: marco é o nome que o projeto dá a cada estado da cena que roda sozinho. A palavra “imutável”, no fim do comentário, quer dizer que nenhum estado seguinte mexe neste arquivo.

A primeira constante é o teto contra o qual a página mede cada quadro, TETO_DO_QUADRO_MS, que vale TETO_DESKTOP_MS, o do computador de mesa. As quatro seguintes repetem os números do estado anterior: o maior lado da morsa, 0,18 metro; o ponto do tampo em que ela é presa; as 24 peças de reposição; e a metade dos vértices que a simplificação tira do nível de longe.

A página monta os elementos na ordem em que quem chega os encontra: o título, o parágrafo com a explicação de uso, a tela de desenho, a fileira dos botões de enquadramento, o botão “Medir agora” e, por último, as duas folhas, a do regime em janela e a de ativos e custo. Em seguida vêm os objetos que os estados anteriores já tinham: o palco, que cuida da tela e da câmera; a cena; o relógio, que mede o tempo entre um quadro e o seguinte; o medidor, que no código se chama Orcamento; e o laço. vestirCena dá às peças a forma e a superfície, a borda parafusada é montada e presa ao tampo, e a prateleira vai para 0,4 metro acima do chão e 2,8 metros para trás.

Só então entra montarOrbita(palco.camera, canvas). A ordem importa: a órbita mede a câmera para tirar dela os três números de partida, e a câmera precisa já estar onde o palco a pôs. Montada antes do palco, a órbita não teria câmera para medir.

atualizarFolhas escreve as duas folhas de uma vez. A do regime recebe as linhas da órbita, a contagem de quadros e as linhas que linhasDaJanela monta com a conferência da entrega e os limites aferidos, seguidas das linhas de custo. A de ativos e custo é a do estado anterior, sem mudança. A função roda na abertura, a cada clique num botão, no clique em “Medir agora” e quando a morsa termina de chegar, e em nenhum outro momento: o número de quadros que a folha mostra é o do instante em que ela foi reescrita.

O clique em “Medir agora” produz as duas medidas na mesma hora: linhasDaMedicao para a folha antiga e custoContraOTeto para a nova, as duas a partir do mesmo orcamento.ler(). A morsa chega como no estado anterior, pela importação, e a cena começa a ser desenhada antes de ela terminar.

O laço, no fim do arquivo, faz quatro coisas por quadro: soma um à contagem de quadros, gira o eixo do mecanismo, avança a animação da morsa e entrega a câmera a prateleira.atualizar, que só a lê. Não há chamada nenhuma à órbita dentro dele.

1.8 A folha do regime, linha a linha

A folha do regime fica logo abaixo dos botões. O texto abaixo é a folha como a página a escreveu numa execução de 03/11/2026, aberta num Chrome sem tela, sem ninguém mexer na cena. As linhas da primeira parte saem na página como parágrafos separados; aqui elas vêm juntas. A página escreve os números com ponto no lugar da vírgula.

A câmera em órbita
Alvo da órbita: 0.00, 0.95, 0.00 m
Azimute 0.0 graus, elevação 21.8 graus, distância 1.62 m
Posição da câmera escrita 1 vez desde que a página abriu
Travas: elevação de 0 a 85 graus, distância de 0.35 a 8 m
Quadros desenhados desde que a página abriu: 5

O que a janela entrega, e o que ela não oferece
Regime: Realidade virtual não imersiva. É o caso base e o destino de quem não tem headset: a bancada inteira precisa ser montável aqui.
O que ele rastreia, como foi declarado: nada do corpo; a câmera obedece ao mouse
A cena inteira cabe na tela a 5.07 m do centro dela (esfera de 1.87 m de raio; a órbita vai até 8 m)

Peças do domínio cobertas pela órbita: 5 de 5
  Corpo: a 0.35 m (a conta pedia 0.24 m), ocupa 51% da altura da tela
  Eixo: a 0.35 m (a conta pedia 0.30 m), ocupa 65% da altura da tela
  Engrenagem grande: a 0.35 m (a conta pedia 0.30 m), ocupa 64% da altura da tela
  Engrenagem pequena: a 0.35 m (a conta pedia 0.18 m), ocupa 37% da altura da tela
  Tampa: a 0.35 m (a conta pedia 0.25 m), ocupa 52% da altura da tela

O que este regime não oferece:
A profundidade é inferida da imagem, e não vista com os dois olhos. [confirmado]
  Por quê: a tela mostra a mesma imagem aos dois olhos; a distância se infere da perspectiva, de qual peça tampa qual e do tamanho relativo, e não da diferença entre duas vistas.
  Impede demonstrar: julgar, sem girar a câmera, qual de duas peças próximas está mais perto, que é o julgamento que encaixar uma peça na outra pede.
  Aferição: o renderizador desenha uma imagem por quadro, a da câmera da página.
Não há escala corporal. [confirmado]
  Por quê: o tampo está a 90 cm do chão da cena, mas o ambiente não sabe onde fica o chão da sala nem a altura dos olhos de quem olha; a bancada aparece do tamanho que a janela tiver.
  Impede demonstrar: que a bancada tem a altura certa para trabalhar de pé diante dela.
  Aferição: o regime foi declarado com o espaço de referência 'viewer', que acompanha o olhar e não tem chão, e nenhum visor está apresentando.
Não há alcance de braço. [inconclusivo]
  Por quê: o cursor chega a qualquer ponto da tela com o mesmo gesto, esteja a peça a 0.35 m ou a 8 m da câmera.
  Impede demonstrar: que uma peça está longe demais para ser apanhada sem dar um passo até ela.
  Aferição: nada no programa mede o braço de quem usa: o cursor não tem corpo.

Contraste, como foi declarado para o regime imersivo: É onde escala corporal e alcance de braço passam a existir — e nenhum dos dois tem equivalente na janela do desktop.

O custo do quadro sai no clique em "Medir agora".

As quatro primeiras linhas depois do título são a câmera da abertura, a contagem de escritas e as travas. A quinta diz que só 5 quadros tinham sido desenhados quando a folha foi escrita pela última vez, logo depois de a morsa chegar; seis segundos depois, um clique em “Medir agora” reescreveu a folha com 1.327 quadros desenhados, e a linha das escritas continuava dizendo “1 vez”.

As duas linhas do regime vêm da declaração que a Bancada fez no primeiro estado, lida por regimePorId('inline'). Headset é a palavra em inglês para o visor. Na frase “a bancada inteira precisa ser montável aqui”, bancada é o projeto inteiro, a Bancada, e não a mesa da cena; a frase fala do que a janela vai ter de oferecer quando a interação chegar; neste estado, ninguém monta nada, e a cena só se olha. A aferição não lê a linha “nada do corpo”: as duas primeiras consultam se há visor apresentando e o espaço de referência declarado.

Cada limite ocupa quatro linhas. A primeira é o enunciado, com o resultado da aferição entre colchetes. As duas seguintes são outras duas partes da definição de limite, o porquê e o que a falta impede demonstrar. A quarta fica fora do limite e diz o que a aferição consultou. Das quatro partes da definição, só o nome não sai na folha, porque é o id do código. Nas linhas da escala corporal, bancada é a mesa da cena. A última linha da folha, por enquanto, só avisa que o custo sai no clique.

1.8.1 Três defeitos que o console não mostra

Os três defeitos abaixo não aparecem no console de erros do navegador: a página continua abrindo, desenhando e respondendo. Cada um se confere na própria página.

A câmera escrita fora da órbita. Clique em “A prateleira do fundo” e arraste a cena um pixel. Se a câmera continuar perto da prateleira, o pedido passou pela órbita. Se ela saltar de volta para a mesa, algum código escreveu a câmera direto, e é esse código que se troca por um pedido à órbita. Na mesma execução, depois de um arrasto, dois passos da roda, uma rolagem de lado e os botões “A cena inteira” e “Engrenagem pequena”, o clique na prateleira deixou a folha assim:

Alvo da órbita: 0.00, 0.69, -2.81 m
Azimute -35.0 graus, elevação 35.8 graus, distância 1.18 m
Posição da câmera escrita 16 vezes desde que a página abriu

O azimute e a elevação são os que o arrasto deixou, e o botão os manteve. Um clique em “Medir agora” com a câmera ali escreveu, na folha de ativos e custo, Medido em 120 quadros: 1 chamada e 5376 triângulos efetivamente desenhados.

Uma peça fora da cobertura. Leia a linha “Peças do domínio cobertas pela órbita”. Cinco de cinco é a conferência da entrega passando. Um número menor quer dizer que alguma peça não foi achada pelo nome (não está na cena, ou foi renomeada nela e não na lista), não tem forma visível ou não cabe na tela. A linha da peça diz “não está na cena” quando ela não foi achada ou não tem forma visível, e mostra uma fração acima de 100% quando ela não cabe.

A medida tirada cedo demais. Aperte “Medir agora” logo que a página abrir e de novo alguns segundos depois. Noutra execução, na mesma máquina e no mesmo dia, o primeiro clique saiu com dois quadros no medidor, e o fim da folha do regime ficou assim:

Trabalho do código por quadro: 13.20 ms em média, em 2 quadros, dentro do teto de 16.7 ms, com 3.5 ms de folga
Intervalo entre imagens: 22.9 ms em média, o pior 45.8 ms
Houve imagem perdida com o custo médio dentro do teto: a média não mostra o quadro perdido, e o atraso pode ter vindo de um quadro em que o código passou do teto ou de fora do código medido, da placa de vídeo, do compositor do sistema ou de outro programa

Com dois quadros no medidor, a média esconde ainda mais do que com 120: 13,20 ms de média em dois quadros admitem um quadro de 26,4 ms e outro de quase nada, e o de 26,4 ms, sozinho, passa do teto. É por isso que a frase não culpa ninguém.

Seis segundos depois, o segundo clique:

Trabalho do código por quadro: 0.20 ms em média, em 120 quadros, dentro do teto de 16.7 ms, com 16.5 ms de folga
Intervalo entre imagens: 4.2 ms em média, o pior 5.9 ms

O custo médio do primeiro clique é perto de sessenta e seis vezes o do segundo: 13{,}20 / 0{,}20 = 66. A acusação de imagem perdida também era dos primeiros quadros, e no segundo clique ela não aparece.

1.8.2 A máquina desta execução

Os números de tempo acima são desta execução, e não do laboratório. A folha de ativos e custo da mesma página relatou a placa como ANGLE (Intel, Intel(R) Graphics (0x00007D67) Direct3D11 vs_5_0 ps_5_0, D3D11): uma placa Intel integrada, acessada pela camada de tradução que o Chrome usa no Windows. O Chrome rodava sem tela, e por isso não estava preso ao ritmo de uma tela de 60 imagens por segundo: o intervalo médio de 4,2 ms quer dizer perto de 240 imagens por segundo.

Na execução da folha transcrita no começo, o clique em “Medir agora” seis segundos depois de abrir, com a cena parada, deu as duas medidas lado a lado. Na folha de ativos e custo, que tira a sobra do intervalo:

Intervalo médio: 4.2 ms — 12.5 ms de folga
Pior intervalo da janela: 4.3 ms

Na folha do regime, que tira a folga do custo: Trabalho do código por quadro: 0.23 ms em média, em 120 quadros, dentro do teto de 16.7 ms, com 16.5 ms de folga. Nesta máquina sem tela, a sobra pelo intervalo, 16{,}7 - 4{,}2 = 12{,}5 ms, não chegou a zero, mas também não é a folga: o código trabalhou 0,23 ms por quadro, e a folga é 16{,}7 - 0{,}23 \approx 16{,}5 ms. Na linha “Pior intervalo da janela”, que vem da folha do estado anterior, janela são os últimos 120 quadros que o medidor guarda, e não o regime.

1.9 O piso para onde a queda leva

A escada parada leva ao andar de cima, mas não a todo lugar. Na janela sobrevive toda tarefa que só pede mudar o ponto de vista: ver cada peça inteira, de qualquer ângulo, chegar à menor e voltar à cena inteira. A escala corporal e o alcance de braço não têm versão em janela, nem reduzida: a tarefa que os pede deixa de existir ali, e mostrar outra coisa no lugar seria um substituto calado. A resposta certa é dizer isso, com o motivo, na lista de limites aferidos. A janela que vai ser destino da queda entre regimes precisa passar por duas perguntas, feitas à própria página.

ImportantePronta para ser destino
  1. Uma pessoa que nunca viu o projeto chega a cada peça do domínio pelos botões e pelos gestos, sem ajuda?
  2. A página diz o que a janela não oferece, e cada linha tem a aferição ao lado?

Com as duas respostas sim, a janela se sustenta sozinha. Com uma delas não, falta trabalho na janela, e nenhum regime construído em cima dela compensa a falta.

As duas tarefas do seu projeto, abaixo, usam palavras que não apareceram até aqui. O regime que roda em qualquer máquina é o regime em janela, e a câmera em órbita controlada pelo cursor é a órbita movida pelo mouse, pelo dedo ou pela caneta. Custo medido dentro do teto quer dizer folga positiva, tirada do custo médio. Os objetos do domínio são as peças do domínio do seu projeto, e “alcança todos os objetos do domínio” é a cobertura do domínio, e não alcance de braço. E o ambiente ganhar corpo é ele chegar ao regime que conhece o chão e rastreia a mão.

Tarefa 1: Entregar o ambiente em janela

O regime que roda em qualquer máquina fecha aqui, e fecha utilizável. Câmera em órbita controlada pelo cursor, cena completa, custo medido dentro do teto, sem exigir equipamento algum além do navegador.

O que fica pronto é um caminho de navegação inteiro, percorrido por tela: quem abre o endereço enxerga a cena, gira em volta dela, aproxima, afasta e alcança todos os objetos do domínio. Este regime é o destino para onde tudo degrada adiante, e por isso precisa bastar-se antes de qualquer outro existir.

Tarefa 2: Registrar o que este regime não oferece

Um regime bem construído é também um regime cujos limites quem o construiu sabe enunciar. Aqui não há escala corporal, não há alcance de braço, e a profundidade é inferida por perspectiva e oclusão em vez de ser vista com dois olhos.

A Tarefa 2 fecha com esses limites escritos e com o que eles impedem de demonstrar. A lista serve de contraste quando o mesmo ambiente ganhar corpo adiante, e impede que a versão em janela seja exibida depois no lugar de um regime imersivo que nunca foi construído.