1 Realidade aumentada: registro, detecção de superfície e ancoragem — Projeto do Professor
Este é o projeto de referência resolvido pelo professor: a implementação que serve de modelo do que cada grupo deve produzir no Projeto Integrador. Ele existe para ser estudado, discutido e criticado — não para ser copiado. O que está aqui é uma solução defensável, com as decisões explicadas uma a uma; a solução de cada grupo será outra, e terá de ser defendida do mesmo jeito.
1.1 Visão geral
“Mouse, controle e dedo são plugues diferentes na mesma tomada — a lógica de montagem só enxerga a tomada.” Hoje o dedo entra, e a frase deixa de ser promessa.
De quantas maneiras já se apanhou a engrenagem grande neste percurso? De duas. Com o cursor, arrastada sobre um plano. Com o controle rastreado, segurada a quarenta centímetros do rosto, com os seis graus de liberdade da mão.
Falta a terceira, e ela acontece sobre uma mesa que existe. A bancada pousa no tampo de fórmica da sala de aula, o dedo encosta na tela, e a peça é apanhada pelo mesmo caminho de sempre. Nenhuma linha de seleção, agarre ou encaixe muda para isso.
Entram cinco arquivos, quatro deles numa pasta nova. A lista dos que não mudaram é o recibo do módulo, e ela é a mesma de sempre: a camada de interação inteira ficou intocada pela terceira vez.
1.2 O momento em que a aparência vira prova
Uma figura que serviu bem por onze módulos morre aqui, e o lugar de anunciar isso é antes de o aluno tropeçar nela.
Presença é o palco acertando escala e tempo; fidelidade é a pintura do cenário, e cenário pintado não segura ninguém no lugar. Essa separação sustentou o percurso inteiro. Ela mandava acertar a altura do tampo antes de caprichar no latão, e mandava certo.
Neste regime ela deixa de valer, e a razão não é estética. Uma bancada com escala correta, latência baixa e registro impecável, desenhada por cima da mão que passa à frente dela, para de convencer. Ninguém acredita que ela está sobre a mesa.
O que mudou é o papel da aparência. No ambiente que substitui o mundo, sombra e oclusão eram enfeite — não havia com que comparar. No ambiente que preserva o mundo, elas são a evidência de que o objeto ocupa aquele lugar, porque tudo em volta as tem.
Compramos alguma das duas? Nenhuma — e isso está escrito no código como dado, não como omissão. O arquivo do regime aumentado carrega os três limites — oclusão, sombra e estimativa de luz —, cada um com o que o olho vê por ele faltar e por que ele ficou de fora. É a diferença entre uma lacuna declarada e um defeito por descobrir.
1.3 Tarefa 1: Ligar o terceiro back-end e abrir a cena sobre o mundo
O plugue que falta é o mais simples dos três, e chega pelo caminho que ninguém adivinha na primeira vez.
Enunciado da tarefa
O toque na tela entra como a última implementação da abstração de apontar, e a sessão passa a compor a cena sobre a imagem do ambiente físico captada pela câmera. A lógica de seleção, agarre e encaixe continua a mesma dos dois regimes anteriores.
O que fica pronto é o ambiente aberto sobre o mundo real, com os objetos do domínio manipuláveis pelo mesmo caminho de sempre. Três formas de apontar, uma lógica só: é a economia do percurso inteiro ficando visível num gesto.
1.3.1 Onde o dedo NÃO chega
A primeira versão deste arquivo foi escrita sobre os eventos de toque do navegador. Ela funcionou perfeitamente no computador de mesa, com o dedo na tela sensível, e não respondeu a nada no celular. Foi meia tarde até a causa aparecer.
Numa sessão aumentada de aparelho de mão, a página não está sendo desenhada. A sessão tomou a tela, e o navegador deixou de entregar evento algum ao elemento de desenho. O toque chega por outro caminho: como fonte de entrada da própria sessão, com modo de raio de tela.
Essa fonte é transitória. Ela nasce quando o dedo encosta e morre quando ele sai, e isso muda um comportamento que o percurso vinha dando de graça. Não existe mira sem toque num aparelho de mão.
src/bancada/input/toque.ts
// ---------------------------------------------------------------------------
// O terceiro e último plugue: o toque na tela.
//
// Mouse, controle e dedo são plugues diferentes na mesma tomada — a lógica de
// montagem só enxerga a tomada. Este arquivo fecha a conta: com ele, a frase
// deixa de ser desenho e passa a ser as três implementações que existem, e a
// camada de interação continua sem uma linha que fale de pixel, de gatilho ou de
// dedo.
//
// O dedo entra por onde ninguém espera na primeira vez. Numa sessão aumentada de
// aparelho de mão, a página não está sendo desenhada — a sessão tomou a tela —, e
// os eventos de toque do navegador não chegam mais ao elemento de desenho.
// Escrever o back-end sobre eles funciona no teste de mesa e produz um ambiente
// que não responde a toque nenhum no celular, que é justamente o aparelho para o
// qual ele foi escrito.
//
// O toque chega, em vez disso, como fonte de entrada da própria sessão, com
// modo de raio DE TELA. Ela é TRANSITÓRIA: nasce quando o dedo encosta e morre
// quando ele sai, e essa é a diferença de comportamento que a abstração absorve
// sem alterações. O cursor dava a mira de graça, por passagem, antes de qualquer
// clique; o dedo não dá. Não existe mira sem toque num aparelho de mão, e por
// isso o realce por passagem — que a camada de seleção implementa desde o módulo
// de interação — simplesmente não tem o que realçar até alguém encostar.
//
// Isso não é limitação a corrigir. É o aparelho, e a resposta certa é deixá-lo
// aparecer: o ponteiro some da lista entre um toque e outro, exatamente como o
// cursor some quando sai da superfície de desenho, e nada acima precisa saber.
//
// A separação com o segundo plugue exigiu dele uma declaração que até agora não
// era necessária. Enquanto só havia controles, toda fonte de entrada da sessão
// era controle; com o dedo na mesma sessão, cada um dos dois passa a dizer qual
// modo de raio atende — e sem isso o mesmo toque seria lido duas vezes, uma como
// dedo e outra como controle rastreado que ninguém segura.
// ---------------------------------------------------------------------------
import {
Object3D,
Quaternion,
Vector3,
type WebGLRenderer,
type XRTargetRaySpace,
} from 'three';
import type { FonteDePonteiro, Ponteiro } from './ponteiro';
/**
* Quantas fontes de toque a fonte acompanha.
*
* Duas, e não uma: a tela reconhece dois dedos ao mesmo tempo, e a camada de
* seleção já sabe lidar com uma lista de ponteiros desde que os dois controles
* chegaram. Ignorar o segundo dedo faria o ambiente parar de responder no meio de
* um gesto de duas mãos, sem nada que explicasse por quê.
*/
const TOQUES_ACOMPANHADOS: number = 2;
/** O eixo do raio, no espaço da fonte. A mesma convenção do controle rastreado. */
const EIXO_DA_MIRA: Vector3 = new Vector3(0, 0, -1);
/** Estado de um dedo entre um quadro e o seguinte. */
interface Dedo {
readonly indice: number;
readonly no: XRTargetRaySpace;
/** Verdadeiro entre o encostar e o sair do dedo. Fora disso a fonte não existe. */
encostado: boolean;
/** Ordem de acionamento. Quem encostou por último tem o número maior. */
ordem: number;
}
// Temporários de leitura, pela mesma razão do controle rastreado: o laço do
// aparelho de mão é o mais apertado dos três, e alocação por quadro aparece nele
// antes de aparecer em qualquer outro.
const posicao: Vector3 = new Vector3();
const orientacao: Quaternion = new Quaternion();
const direcao: Vector3 = new Vector3();
/**
* Instala a fonte de toque sobre o renderizador e a raiz da cena.
*
* Os nós de mira são os mesmos objetos que a biblioteca entrega por índice, e é
* por isso que este arquivo NÃO os cria nem os destrói: ele apenas escuta os
* avisos deles e devolve leitura quando o modo de raio for o de tela. Quem os
* anexa à árvore é a fonte que chegou primeiro, e a travessia que atualiza a
* matriz de mundo deles serve às duas.
*/
export function montarFonteDeToque(renderer: WebGLRenderer, raiz: Object3D): FonteDePonteiro {
let contadorDeOrdem: number = 0;
const dedos: Dedo[] = [];
const desligadores: (() => void)[] = [];
for (let indice: number = 0; indice < TOQUES_ACOMPANHADOS; indice += 1) {
const no: XRTargetRaySpace = renderer.xr.getController(indice);
const dedo: Dedo = { indice, no, encostado: false, ordem: 0 };
function aoConectar(evento: { data: XRInputSource }): void {
// A verificação do modo de raio é o que separa este plugue do anterior. Sem
// ela, um controle rastreado alimentaria a fonte de toque e um toque
// alimentaria a fonte de controles, e o sintoma seria a peça sendo apanhada
// duas vezes no mesmo gesto.
if (evento.data.targetRayMode !== 'screen') {
return;
}
dedo.encostado = true;
contadorDeOrdem += 1;
dedo.ordem = contadorDeOrdem;
}
function aoDesconectar(): void {
// O dedo saiu da tela e a fonte de entrada deixou de existir. O ponteiro
// some da lista, e quem estava com uma peça na mão continua com ela: soltar
// é decisão da camada de manipulação, tomada pelo estado do agarre, e não
// consequência de o aparelho ter deixado de reportar a fonte.
dedo.encostado = false;
}
no.addEventListener('connected', aoConectar);
no.addEventListener('disconnected', aoDesconectar);
desligadores.push((): void => {
no.removeEventListener('connected', aoConectar);
no.removeEventListener('disconnected', aoDesconectar);
});
raiz.add(no);
dedos.push(dedo);
}
function lerDedo(dedo: Dedo): Ponteiro {
dedo.no.getWorldPosition(posicao);
dedo.no.getWorldQuaternion(orientacao);
direcao.copy(EIXO_DA_MIRA).applyQuaternion(orientacao).normalize();
return {
id: `toque-${dedo.indice}`,
regime: 'aumentado',
// A origem é a própria câmera do aparelho, e a direção passa pelo pixel
// tocado: é a mesma geometria do cursor, com a diferença de que aqui quem
// faz a conta é a plataforma, porque só ela sabe onde a câmera está.
origem: posicao.clone(),
direcao: direcao.clone(),
// Enquanto o dedo está na tela, há acionamento. Não existe o estado
// intermediário do cursor — mira sem clique —, e é essa ausência que o
// comentário do topo do arquivo descreve.
acionado: true,
// A pega vem do próprio toque mantido, como no controle. Um dedo que
// encosta e não sai é um dedo que está segurando.
agarre: true,
estado: 'acionado',
// Sem pose. O aparelho de mão rastreia a si mesmo, e não a mão de quem o
// segura: não há mão a que prender a peça, e a manipulação cai no mesmo
// caminho por raio e plano que o cursor já usava.
pose: undefined,
};
}
return {
nome: 'toque na tela do aparelho de mão',
regime: 'aumentado',
ler(): readonly Ponteiro[] {
if (!renderer.xr.isPresenting) {
return [];
}
const ativos: Dedo[] = dedos.filter((dedo) => dedo.encostado);
// A mesma regra do controle rastreado, e pelo mesmo motivo: quem tocou por
// último manda. Ela mora aqui porque é conhecimento do aparelho, e a camada
// de seleção continua lendo o primeiro da lista sem saber contar dedos.
ativos.sort((a: Dedo, b: Dedo) => b.ordem - a.ordem);
return ativos.map(lerDedo);
},
encerrar(): void {
for (const desligar of desligadores) {
desligar();
}
desligadores.length = 0;
dedos.length = 0;
},
};
}O realce por passagem, que o cursor oferecia antes de qualquer clique, simplesmente não tem o que realçar aqui. E isso é o aparelho, e a resposta certa é deixá-la aparecer. O ponteiro some da lista entre um toque e outro, exatamente como o cursor some ao sair da superfície de desenho.
O terceiro plugue cobrou do segundo uma declaração que até agora ninguém precisava fazer. Enquanto só havia controles, toda fonte de entrada da sessão era controle, e verificar isso seria ruído. Com o dedo na mesma sessão, cada plugue passa a dizer qual modo de raio atende — sem isso, o mesmo toque seria lido duas vezes, e a peça seria apanhada e largada no mesmo gesto.
1.3.2 Duas famílias de apontar, uma sessão só
Aqui a tomada precisou de uma mudança, e ela é a primeira em três módulos. A sessão aumentada é aberta tanto pelo aparelho de mão, cuja entrada é o dedo, quanto pelo visor com passagem de vídeo, cuja entrada continua sendo o controle rastreado.
O regime da sessão é um só. As fontes que ele admite são duas. Ativar apenas o regime aumentado calaria os controles dentro do visor; ativar apenas o imersivo calaria o dedo no celular. Nenhum dos dois casos daria erro, e os dois deixariam metade da turma sem apontar.
A saída foi deixar a tomada aprender uma lista de regimes ativos, em vez de um valor. A alternativa era a composição perguntar, por aparelho, se há controle — e essa pergunta acima da tomada é exatamente o conhecimento que ela existe para conter.
1.3.3 A linha que apaga o mundo
A operação que abre a cena sobre o ambiente é decepcionante de tão curta: apaga-se o fundo da cena. O que estava atrás dele passa a ser a sala.
A demonstração mais barata do módulo se faz sem escrever nada. Entra-se em sessão aumentada com o fundo cinza ainda pintado, e o visor mostra um retângulo opaco no lugar do mundo. Depois disso ninguém precisa que a palavra composição seja definida.
src/bancada/modes/aumentado.ts
// ---------------------------------------------------------------------------
// O terceiro regime: compor a cena sobre a imagem do ambiente físico.
//
// A operação técnica é quase decepcionante de tão curta — apaga-se o fundo da
// cena, e o que estava atrás dele passa a ser a sala. O que ela custa é entender
// o que essa linha significa, e a demonstração mais barata do módulo é feita sem
// código: entra-se em sessão aumentada com o fundo opaco ainda pintado, e o
// visor mostra um retângulo cinza no lugar do mundo. Ninguém precisa explicar o
// que é composição depois disso.
//
// O que este arquivo NÃO faz é a parte mais importante dele, e está escrito como
// dado logo abaixo. A bancada não é ocluída pelos objetos reais, não projeta
// sombra na mesa e não recebe a cor da luz do cômodo. Nenhuma dessas três coisas
// é esquecimento: são capacidades que existem, que custam caro e que este
// percurso não compra. Declará-las é o que impede a conclusão errada — a de que
// o registro está ruim quando, na verdade, ele está certo e a APARÊNCIA é que
// não o confirma.
//
// E é aqui que uma figura útil do começo do percurso encontra o seu limite.
// Presença é o palco acertando escala e tempo; fidelidade é a pintura do
// cenário, e cenário pintado não segura ninguém no lugar. Isso vale nos dois
// regimes anteriores e deixa de valer neste: um objeto com escala e latência
// corretas, sem oclusão e sem sombra, para de convencer — não porque esteja
// feio, mas porque a aparência virou EVIDÊNCIA de que ele ocupa aquele lugar. A
// sombra deixou de ser pintura e passou a ser prova.
// ---------------------------------------------------------------------------
import { Color, Scene, type Texture } from 'three';
import { regimePorId, type ModoDeComposicao } from './regimes';
import type { SessaoAberta } from './sessao';
/** Uma capacidade que existe na área, que a aparência cobra, e que não está aqui. */
export interface LimiteDaComposicao {
readonly capacidade: string;
/** O que o olho vê por ela faltar. */
readonly oQueOOlhoVe: string;
/** Por que ela não foi comprada neste percurso. */
readonly porQueNaoEstaAqui: string;
}
/**
* Os três limites da composição, escritos antes de alguém os descobrir na
* demonstração.
*
* A ordem é a do quanto cada um denuncia o objeto virtual. A oclusão é a
* primeira porque é a que qualquer pessoa nota sem ser avisada: a bancada
* aparecendo por cima da mão de quem a toca é o defeito que a turma inteira
* enxerga ao mesmo tempo.
*/
export const LIMITES_DA_COMPOSICAO: readonly LimiteDaComposicao[] = [
{
capacidade: 'oclusão pelo ambiente real',
oQueOOlhoVe:
'a bancada continua desenhada por cima da mão que passa à frente dela e da cadeira ' +
'que deveria escondê-la',
porQueNaoEstaAqui:
'exige o mapa de profundidade do aparelho como textura de teste, com formato próprio ' +
'pedido na abertura da sessão; um pedido malformado derruba a sessão inteira em vez ' +
'de apenas negar o recurso',
},
{
capacidade: 'sombra projetada sobre a superfície real',
oQueOOlhoVe:
'a bancada parece flutuar um dedo acima da mesa, mesmo estando exatamente sobre ela',
porQueNaoEstaAqui:
'exige superfície receptora de sombra alinhada ao plano detectado e um mapa de sombra ' +
'no orçamento de quadro, que é o mesmo orçamento que o conforto do visor consome',
},
{
capacidade: 'estimativa da luz do cômodo',
oQueOOlhoVe:
'a bancada é iluminada pelas duas luzes que nós declaramos, e não pela janela que está ' +
'à esquerda de quem olha; o latão brilha do lado errado',
porQueNaoEstaAqui:
'é capacidade concedida por poucos aparelhos e consumida por quadro; entra na lista de ' +
'extensões da demonstração, não no núcleo',
},
];
export interface RegimeAumentado {
/** Apaga o fundo e confronta a composição declarada com a que a sessão informa. */
compor(aberta: SessaoAberta): void;
/** Devolve o fundo. A sessão terminou, e a janela precisa dele de volta. */
desfazer(): void;
composto(): boolean;
linhas(): string[];
}
export function montarRegimeAumentado(sala: Scene): RegimeAumentado {
const fundoOriginal: Color | Texture | null = sala.background;
let ativo: boolean = false;
let informada: ModoDeComposicao | undefined = undefined;
return {
compor(aberta: SessaoAberta): void {
// O fundo nulo é toda a operação. O que ele apaga é a cor que preenchia o
// quadro antes de qualquer objeto ser desenhado; sem ela, os pixels em que
// nada foi desenhado saem transparentes, e o aparelho compõe o que a câmera
// dele está vendo por baixo.
sala.background = null;
ativo = true;
// Sem conversão: a união que a especificação declara para o modo de
// composição é a mesma que o primeiro módulo escreveu à mão, e é essa
// coincidência deliberada que permite comparar os dois sem tradução.
informada = aberta.composicao;
},
desfazer(): void {
sala.background = fundoOriginal;
ativo = false;
informada = undefined;
},
composto(): boolean {
return ativo;
},
linhas(): string[] {
const esperada: ModoDeComposicao = regimePorId('immersive-ar').composicaoEsperada;
const linhas: string[] = [
`Composição sobre o ambiente: ${ativo ? 'ligada' : 'desligada'}.`,
];
if (informada === undefined) {
linhas.push(
`Composição declarada para este regime no primeiro módulo: ${esperada}. A leitura do ` +
'valor real só existe com sessão aumentada aberta.',
);
} else {
// O confronto que o primeiro módulo prometeu. A declaração dos três
// regimes foi escrita antes de haver sessão, justamente para poder ser
// cobrada aqui — e o caso interessante não é o acerto, é a divergência.
linhas.push(
`Declarado no primeiro módulo: ${esperada}. Informado por esta sessão: ${informada}.`,
informada === esperada
? 'A declaração se confirmou neste aparelho.'
: 'Divergência, e ela é fato do aparelho, não defeito da declaração: fundo aditivo ' +
'soma luz em vez de substituir pixel, e o preto da cena simplesmente não aparece.',
);
if (informada === 'opaque') {
linhas.push(
'A sessão abriu em modo opaco: este aparelho não compõe sobre o mundo. O ambiente ' +
'está correto e o mundo está tapado — é limite do aparelho, e a mensagem certa a ' +
'quem usa diz isso, não "erro ao iniciar".',
);
}
}
linhas.push('');
for (const limite of LIMITES_DA_COMPOSICAO) {
linhas.push(
`${limite.capacidade}: o olho vê ${limite.oQueOOlhoVe}. Fora do percurso porque ` +
`${limite.porQueNaoEstaAqui}.`,
);
}
return linhas;
},
};
}Este arquivo também cobra uma promessa antiga. No primeiro módulo declaramos, por escrito e sem sessão nenhuma aberta, qual composição de fundo cada regime deveria informar. Agora há sessão, e a declaração é confrontada com o que o aparelho responde.
O caso interessante é a divergência: um fundo aditivo soma luz em vez de substituir pixel, e o preto da cena não aparece de jeito nenhum. E há o caso extremo, que também está tratado — a sessão que abre em modo opaco simplesmente não compõe sobre o mundo. Ali o ambiente está correto e o aparelho é que não faz aquilo, e a mensagem honesta diz isso.
1.4 Tarefa 2: Pousar o objeto sobre a superfície detectada
Esta é a tarefa em que o esvaziamento mais comum da disciplina acontece, e ele impressiona na tela.
Enunciado da tarefa
O objeto virtual passa a se localizar em relação ao que o aparelho detectou do ambiente — a superfície encontrada, a pose estimada, a âncora criada —, e não em relação à tela. Um desenho fixo por cima do vídeo usa a câmera como papel de parede, e o assunto deste trecho passa ao largo.
A tarefa fecha quando a consulta de interseção contra a superfície detectada decide onde o objeto pousa, e quando ele permanece onde foi posto enquanto quem observa caminha em volta. O sensor do aparelho precisa estar produzindo o dado de mundo que o ambiente consome.
1.4.1 Contra o que o raio é lançado
A pergunta que separa realidade aumentada de desenho sobre vídeo cabe em cinco palavras: contra o que o raio é lançado?
Nos módulos anteriores ele era lançado contra a árvore da cena. Objetos que nós criamos, cujas posições nós escrevemos, cuja existência é decisão nossa. Aqui ele é lançado contra superfícies que ninguém modelou.
Elas existem porque o aparelho desenha a planta da sala enquanto atravessa a sala, e corrige o desenho toda vez que reconhece um canto por onde já passou. O cartógrafo do módulo de dispositivos vem trabalhando desde que a sessão abriu, e a consulta de interseção é a primeira vez que o projeto pergunta a ele alguma coisa.
src/bancada/anchoring/superficie.ts
// ---------------------------------------------------------------------------
// A consulta de interseção contra o que o aparelho encontrou do ambiente.
//
// Este é o arquivo que separa realidade aumentada de desenho sobre vídeo, e a
// diferença cabe numa pergunta: contra o que o raio é lançado? Nos módulos
// anteriores ele era lançado contra a árvore da cena — objetos que nós mesmos
// criamos, cujas posições nós escrevemos. Aqui ele é lançado contra superfícies
// que ninguém modelou, que o aparelho descobriu enquanto a pessoa andava pela
// sala, e que só existem porque o cartógrafo do módulo de dispositivos vem
// desenhando a planta do cômodo desde que a sessão abriu.
//
// A consulta é da plataforma, e é ela quem responde. O que o projeto faz é pedir
// a fonte uma vez, ler o resultado a cada quadro e desenhar o retículo onde a
// resposta caiu. A tentação de "melhorar" a resposta com um plano suposto no
// nível do chão é o esvaziamento do módulo inteiro: um plano suposto acerta na
// sala em que se testou e erra em qualquer outra.
//
// A fonte é pedida contra o espaço do VISOR, e não contra o espaço de trabalho.
// O raio sai do meio do campo de visão e acompanha a cabeça — quem escolhe onde
// a bancada pousa é quem olha, não uma direção fixa da sala. Pedir contra o
// espaço de trabalho daria um raio parado, apontando sempre para o mesmo canto,
// e a demonstração pareceria funcionar até alguém virar a cabeça.
// ---------------------------------------------------------------------------
import { Mesh, MeshBasicMaterial, Object3D, RingGeometry } from 'three';
import { assentarNoEspacoLocal, type QuadroDaSessao } from './espaco';
/** Por que não há resposta agora. Nenhum destes casos é erro de código. */
export type MotivoDeAusencia =
| 'fora de sessão'
| 'a consulta não foi concedida por este aparelho'
| 'a consulta ainda está sendo preparada'
| 'nenhuma superfície encontrada sob a mira';
/** Raio interno e externo do anel do retículo, em metros. */
const RAIO_INTERNO_M: number = 0.06;
const RAIO_EXTERNO_M: number = 0.08;
/** Uma resposta da consulta, já no espaço de referência da sessão. */
export interface AcertoDeSuperficie {
/** O resultado bruto. É dele que a âncora nasce, e por isso ele viaja junto. */
readonly resultado: XRHitTestResult;
/** A pose do ponto atingido, em coordenadas do espaço de referência. */
readonly matriz: Float32Array;
}
export interface ConsultaDeSuperficie {
/** O anel que marca onde a resposta caiu. Filho da raiz, como tudo o mais. */
readonly no: Object3D;
/** Pede a fonte de consulta à sessão recém-aberta. Uma vez por sessão. */
preparar(sessao: XRSession): void;
atualizar(contexto: QuadroDaSessao | undefined): void;
/** A última resposta válida, ou nada quando não há superfície sob a mira. */
acerto(): AcertoDeSuperficie | undefined;
/** Cancela a fonte e esquece o estado. A sessão terminou. */
esquecer(): void;
linhas(): string[];
}
export function montarConsultaDeSuperficie(raiz: Object3D): ConsultaDeSuperficie {
// O anel deitado sobre o plano do piso: a geometria nasce de pé, e o giro em
// torno do eixo lateral a deita. Sem ele o retículo aparece como uma linha
// fina quando visto de cima, que é exatamente de onde ele é olhado.
const anel: Mesh = new Mesh(
new RingGeometry(RAIO_INTERNO_M, RAIO_EXTERNO_M, 32).rotateX(-Math.PI / 2),
// Material sem iluminação, e de propósito: o retículo é instrumento de mira,
// não objeto da cena. Escurecer com a luz o tornaria ilegível justamente na
// sala mal iluminada, que é onde a detecção já é mais difícil.
new MeshBasicMaterial({ color: 0x4f7cff }),
);
anel.name = 'reticulo';
anel.visible = false;
raiz.add(anel);
let fonte: XRHitTestSource | undefined = undefined;
let pedida: boolean = false;
let motivo: MotivoDeAusencia = 'fora de sessão';
let ultimo: AcertoDeSuperficie | undefined = undefined;
let quadrosComResposta: number = 0;
let quadrosSemResposta: number = 0;
return {
no: anel,
preparar(sessao: XRSession): void {
if (pedida) {
return;
}
pedida = true;
motivo = 'a consulta ainda está sendo preparada';
// O método é opcional na especificação, e a ausência dele é a resposta de
// um aparelho que abriu sessão aumentada sem conceder a consulta. Chamar
// sem verificar produziria uma exceção dentro do laço, no visor, onde
// ninguém abre console para lê-la.
const pedirFonte:
| ((opcoes: XRHitTestOptionsInit) => Promise<XRHitTestSource> | undefined)
| undefined = sessao.requestHitTestSource;
if (pedirFonte === undefined) {
motivo = 'a consulta não foi concedida por este aparelho';
return;
}
void sessao
.requestReferenceSpace('viewer')
.then((espacoDoVisor: XRReferenceSpace): Promise<XRHitTestSource> | undefined =>
// A lista de tipos de alvo fica de fora do pedido de propósito.
// Restringir a planos recusaria a malha reconstruída que os aparelhos
// mais recentes oferecem, e o pouso ficaria pior justamente no aparelho
// que sabe mais sobre a sala.
pedirFonte.call(sessao, { space: espacoDoVisor }),
)
.then((obtida: XRHitTestSource | undefined): void => {
if (obtida === undefined) {
motivo = 'a consulta não foi concedida por este aparelho';
return;
}
fonte = obtida;
motivo = 'nenhuma superfície encontrada sob a mira';
})
.catch((): void => {
// A recusa é resposta, e não falha: o aparelho não tem a capacidade, ou
// a sessão não a concedeu. O ambiente segue de pé sem ela, e o que muda
// é que a bancada não pousa — o que precisa ser lido, não escondido.
motivo = 'a consulta não foi concedida por este aparelho';
});
},
atualizar(contexto: QuadroDaSessao | undefined): void {
if (contexto === undefined || fonte === undefined) {
anel.visible = false;
ultimo = undefined;
return;
}
const respostas: XRHitTestResult[] = contexto.quadro.getHitTestResults(fonte);
if (respostas.length === 0) {
// Sem superfície sob a mira. Acontece o tempo todo em uso real — apontar
// para o vão de uma porta, para uma parede de vidro, para o teto —, e o
// retículo apagar é a resposta certa. Deixá-lo aceso na última posição
// conhecida faria alguém pousar a bancada onde nada foi encontrado.
anel.visible = false;
ultimo = undefined;
motivo = 'nenhuma superfície encontrada sob a mira';
quadrosSemResposta += 1;
return;
}
// A primeira resposta é a mais próxima da origem do raio, e é a que
// interessa: a mesa antes da parede atrás dela.
const primeira: XRHitTestResult = respostas[0];
const pose: XRPose | undefined = primeira.getPose(contexto.espaco);
if (pose === undefined) {
anel.visible = false;
ultimo = undefined;
quadrosSemResposta += 1;
return;
}
ultimo = { resultado: primeira, matriz: pose.transform.matrix };
assentarNoEspacoLocal(pose.transform.matrix, raiz, anel);
anel.visible = true;
quadrosComResposta += 1;
},
acerto(): AcertoDeSuperficie | undefined {
return ultimo;
},
esquecer(): void {
fonte?.cancel();
fonte = undefined;
pedida = false;
ultimo = undefined;
anel.visible = false;
motivo = 'fora de sessão';
quadrosComResposta = 0;
quadrosSemResposta = 0;
},
linhas(): string[] {
const total: number = quadrosComResposta + quadrosSemResposta;
const proporcao: string =
total === 0
? 'ainda não houve quadro consultado'
: `${((quadrosComResposta / total) * 100).toFixed(0)}% dos ${total} quadros consultados`;
return [
`Consulta de interseção: ${fonte === undefined ? 'indisponível' : 'ativa'}.`,
fonte === undefined
? `Motivo: ${motivo}.`
: `Superfície sob a mira agora: ${ultimo === undefined ? 'nenhuma' : 'sim'}.`,
`Encontrou superfície em ${proporcao}.`,
'O raio sai do meio do campo de visão e acompanha a cabeça: quem escolhe onde a bancada ' +
'pousa é quem olha.',
'A proporção acima é medida da SALA, e não do código. Sobre a mesa lisa e sob luz fraca ' +
'ela cai, e é isso que se leva para o relatório do ensaio.',
];
},
};
}A fonte é pedida contra o espaço do visor, e não contra o espaço de trabalho. O raio sai do meio do campo de visão e acompanha a cabeça, de modo que quem escolhe onde a bancada pousa é quem olha. Pedir contra o espaço de trabalho daria um raio parado, apontando sempre para o mesmo canto — e a demonstração pareceria funcionar até alguém virar a cabeça.
A tentação de melhorar a resposta é o esvaziamento inteiro do módulo. Um plano suposto na altura do chão acerta na sala em que se testou e erra em qualquer outra, e o código que o contém parece código de realidade aumentada. A regra que adotamos é simples: a resposta é da plataforma, e o que ela não encontra não se inventa.
O retículo apaga quando nada é encontrado, e isso acontece o tempo todo em uso real. Apontar para o vão de uma porta, para uma parede de vidro, para o teto. Deixá-lo aceso na última posição conhecida faria alguém pousar a bancada onde superfície nenhuma existe.
1.4.2 O espaço em que a resposta vem escrita
Toda pose que a sessão devolve neste módulo vem em coordenadas do espaço de referência. Tudo o que a Bancada desenha vive dentro da raiz da cena, que tem transformação própria — o módulo de escala corporal desce o mundo inteiro quando o aparelho concede a altura da cabeça em vez do piso.
Somar as duas coisas sem converter produz o defeito mais caro daqui, e ele não acusa erro em lugar nenhum. A bancada pousa sobre a mesa num aparelho e meio metro abaixo dela no aparelho ao lado, que é justamente o que concedeu menos.
Quem vê conclui que o registro é impreciso. O registro está correto: errado é o espaço em que a resposta foi escrita.
src/bancada/anchoring/espaco.ts
// ---------------------------------------------------------------------------
// A ponte entre dois espaços que o percurso manteve separados até aqui.
//
// Tudo o que a sessão devolve neste módulo — a pose de uma superfície real, a
// pose de uma âncora, a pose da cabeça — vem em coordenadas do ESPAÇO DE
// REFERÊNCIA, que é o mesmo em que a câmera do visor é colocada. Tudo o que a
// Bancada desenha vive dentro da raiz da cena, que tem transformação própria: o
// módulo de escala corporal desce o mundo inteiro quando o aparelho concede a
// altura da cabeça em vez do piso real, e essa descida está guardada ali.
//
// Somar as duas coisas sem converter produz o defeito mais caro deste módulo, e
// ele não acusa erro em lugar nenhum: a bancada pousa na mesa em um aparelho e
// meio metro abaixo dela no aparelho ao lado, que é justamente o que concedeu
// menos. Quem vê conclui que o registro é impreciso, e o registro está correto —
// o que está errado é o espaço em que a resposta foi escrita.
//
// A conta é uma multiplicação de matrizes e cabe em três linhas. O que ela custa
// é a lembrança de fazê-la, e é por isso que ela mora em arquivo próprio, com
// nome que diz o que faz, em vez de repetida dentro de cada um dos três módulos
// que precisam dela.
// ---------------------------------------------------------------------------
import { Matrix4, Object3D } from 'three';
/**
* O que um quadro de sessão entrega, reunido antes de ser distribuído.
*
* Os três módulos de ancoragem precisam exatamente destas três coisas, e nenhum
* deles tem como obtê-las sozinho: o quadro chega pelo laço, o espaço vem do
* renderizador e a sessão vem do ciclo. Montar o trio uma vez por quadro, na
* composição, evita que cada módulo repita a mesma sequência de três verificações
* de ausência — e evita, sobretudo, que um deles a esqueça.
*/
export interface QuadroDaSessao {
readonly quadro: XRFrame;
readonly espaco: XRReferenceSpace;
readonly sessao: XRSession;
}
// Temporários de conversão. A conversão acontece algumas vezes por quadro, e
// alocar matriz nova a cada uma põe trabalho no coletor de lixo dentro do laço
// que menos pode engasgar.
const doEspacoDeReferencia: Matrix4 = new Matrix4();
const inversaDaRaiz: Matrix4 = new Matrix4();
const noEspacoLocal: Matrix4 = new Matrix4();
/**
* Escreve, em um nó da cena, a pose que a sessão devolveu.
*
* A matriz de entrada está no espaço de referência; o nó de destino é filho da
* raiz, logo lê a transformação dela. A raiz tem a sua matriz de mundo atualizada
* antes da leitura porque a travessia que a atualiza acontece na renderização, e
* este código roda antes dela: sem esta linha, o primeiro quadro depois de um
* assentamento usaria a transformação anterior.
*/
export function assentarNoEspacoLocal(
matriz: Float32Array,
raiz: Object3D,
destino: Object3D,
): void {
doEspacoDeReferencia.fromArray(matriz);
assentarMatrizNoEspacoLocal(doEspacoDeReferencia, raiz, destino);
}
/**
* A mesma conversão, com a matriz já montada.
*
* O registro por marcador chega ao arquivo por esta porta, e a diferença entre
* as duas entradas é só a procedência da matriz: da sessão, no caso da âncora,
* e da própria imagem da câmera, no caso do papel impresso. A conta de converter
* para o espaço da raiz é idêntica, e duplicá-la em outro arquivo seria repetir
* exatamente o esquecimento que este existe para impedir.
*/
export function assentarMatrizNoEspacoLocal(
matriz: Matrix4,
raiz: Object3D,
destino: Object3D,
): void {
if (matriz !== doEspacoDeReferencia) {
doEspacoDeReferencia.copy(matriz);
}
raiz.updateWorldMatrix(true, false);
inversaDaRaiz.copy(raiz.matrixWorld).invert();
noEspacoLocal.multiplyMatrices(inversaDaRaiz, doEspacoDeReferencia);
// A decomposição é o que permite ao nó continuar com atualização automática de
// matriz. Escrever a matriz direto exigiria desligá-la, e um nó com atualização
// desligada ignora em silêncio qualquer mudança de posição feita depois.
noEspacoLocal.decompose(destino.position, destino.quaternion, destino.scale);
}A conta é uma multiplicação de matrizes e cabe em três linhas. O que ela custa é a lembrança de fazê-la, e é por isso que ela mora em arquivo próprio, com nome que diz o que faz. Repetida dentro de cada módulo que precisa dela, ela seria esquecida em um deles.
Este arquivo carrega também o trio que um quadro de sessão entrega. Os três módulos de ancoragem precisam exatamente do quadro, do espaço e da sessão, e nenhum deles obtém as três coisas sozinho. Montar o trio uma vez, na composição, evita que cada um repita as três verificações de ausência — e evita, sobretudo, que um deles as esqueça.
1.4.3 Pôr num lugar, e ficar nesse lugar
A consulta de interseção responde uma vez, e a resposta envelhece. Ela diz onde a mesa estava segundo o mapa daquele quadro, e o mapa continua sendo corrigido depois — a cada canto reconhecido, a cada volta em torno da sala.
Uma bancada escrita com a resposta de um quadro e esquecida ali deriva junto com o erro do mapa antigo. A deriva é visível a olho nu, e é exatamente o teste que o enunciado pede: quem anda em volta da mesa vê a bancada escorregar sobre ela.
A âncora é o pedido para que a plataforma continue respondendo. Em vez de guardar coordenadas, guarda-se um compromisso: este ponto, aquele lugar do mapa. A cada quadro pergunta-se de novo onde ele está agora.
src/bancada/anchoring/ancora.ts
// ---------------------------------------------------------------------------
// A âncora: a diferença entre pôr a bancada num lugar e ela FICAR nesse lugar.
//
// A consulta de superfície responde uma vez, e a resposta envelhece. Ela diz
// onde a mesa estava segundo o mapa que o aparelho tinha naquele quadro, e esse
// mapa continua sendo corrigido depois — a cada canto reconhecido, a cada volta
// completa em torno da sala. Uma bancada escrita com a resposta de um quadro e
// esquecida ali deriva junto com o erro do mapa antigo, e a deriva é visível a
// olho nu: quem anda em volta da mesa vê a bancada escorregar sobre ela.
//
// A âncora é o pedido para que a plataforma CONTINUE respondendo. Em vez de
// guardar coordenadas, guarda-se um compromisso: este ponto, aquele lugar do
// mapa. A cada quadro pergunta-se de novo onde ele está agora, e a resposta muda
// sozinha quando o aparelho corrige o mapa. É a única coisa deste percurso em
// que a posição de um objeto do projeto é escrita por outra pessoa.
//
// O caminho de degradação está declarado e não é silencioso. Onde a âncora não
// existe — porque o aparelho não a concedeu —, a bancada é registrada uma vez e
// fica onde caiu. Isso funciona por alguns minutos e depois deriva, e a diferença
// entre os dois casos é exatamente o que se pede para o aluno observar em vez de
// ouvir: são os mesmos gestos, o mesmo código, e um objeto que fica contra um que
// escorrega.
// ---------------------------------------------------------------------------
import { Object3D, Vector3 } from 'three';
import { assentarNoEspacoLocal, type QuadroDaSessao } from './espaco';
import type { AcertoDeSuperficie } from './superficie';
/** Em que regime de registro a bancada está agora. */
export type EstadoDaAncora =
| 'sem pouso' // ainda não se escolheu onde ela fica
| 'ancorada' // a plataforma corrige a posição a cada quadro
| 'registro estático' // pousou uma vez e ninguém mais corrige — vai derivar
| 'âncora perdida'; // havia âncora, e o aparelho a descartou
export interface Ancoragem {
/** Pousa a bancada no ponto que a consulta devolveu. Assíncrono por dentro. */
pousar(acerto: AcertoDeSuperficie): void;
atualizar(contexto: QuadroDaSessao | undefined): void;
/** Suspende a aplicação das poses. Usado enquanto o rastreamento está perdido. */
congelar(congelado: boolean): void;
/** Desfaz o pouso e devolve a bancada ao lugar de origem. */
soltar(): void;
pousada(): boolean;
estado(): EstadoDaAncora;
linhas(): string[];
}
/**
* Instala a ancoragem sobre a raiz da cena e o nó que pousa.
*
* Quem pousa é a bancada inteira, e não a cena: a cena é a raiz, e mover a raiz
* moveria junto o retículo, o aviso de rastreamento e a própria origem contra a
* qual tudo isto é medido. O nó que pousa é filho da raiz, e é essa separação que
* mantém a conta legível.
*/
export function montarAncoragem(raiz: Object3D, pousa: Object3D): Ancoragem {
const posicaoDeOrigem: Vector3 = pousa.position.clone();
let ancora: XRAnchor | undefined = undefined;
let estadoAtual: EstadoDaAncora = 'sem pouso';
let congelado: boolean = false;
let pedindo: boolean = false;
let porQueEstatico: string = '';
// A medida que interessa ao aluno: quanto o aparelho corrigiu desde o pouso.
// Ela é a evidência de que a âncora não é enfeite — em uma volta em torno da
// mesa, ela costuma passar de um centímetro, e essa é a distância que a
// bancada teria escorregado sem ela.
const posicaoDoPouso: Vector3 = new Vector3();
const posicaoAgora: Vector3 = new Vector3();
let correcaoAcumuladaM: number = 0;
let quadrosSemPoseDaAncora: number = 0;
function registrarEstatico(acerto: AcertoDeSuperficie, porQue: string): void {
assentarNoEspacoLocal(acerto.matriz, raiz, pousa);
posicaoDoPouso.copy(pousa.position);
estadoAtual = 'registro estático';
porQueEstatico = porQue;
}
return {
pousar(acerto: AcertoDeSuperficie): void {
if (pedindo) {
return;
}
pedindo = true;
correcaoAcumuladaM = 0;
quadrosSemPoseDaAncora = 0;
// A criação da âncora nasce do próprio resultado da consulta, e não de uma
// pose recopiada por nós. A diferença importa: o resultado carrega a qual
// parte do mapa aquele ponto pertence, e é esse vínculo que a âncora
// preserva. Recriar a pose à mão produziria uma âncora presa a um ponto
// solto do espaço, que é quase — e não exatamente — a mesma coisa.
const criar: (() => Promise<XRAnchor>) | undefined = acerto.resultado.createAnchor;
if (criar === undefined) {
pedindo = false;
registrarEstatico(
acerto,
'este aparelho não oferece âncoras, e o registro fica sem quem o corrija',
);
return;
}
// A pose é aplicada JÁ, antes de a âncora chegar. A criação é assíncrona e
// leva alguns quadros; sem isto, a bancada ficaria no lugar antigo durante
// esse tempo, e quem tocou a tela veria o gesto não responder.
assentarNoEspacoLocal(acerto.matriz, raiz, pousa);
posicaoDoPouso.copy(pousa.position);
void criar
.call(acerto.resultado)
.then((criada: XRAnchor): void => {
ancora = criada;
estadoAtual = 'ancorada';
porQueEstatico = '';
})
.catch((erro: unknown): void => {
estadoAtual = 'registro estático';
porQueEstatico =
erro instanceof Error
? `a criação da âncora foi recusada: ${erro.message}`
: 'a criação da âncora foi recusada, sem motivo informado';
})
.finally((): void => {
pedindo = false;
});
},
atualizar(contexto: QuadroDaSessao | undefined): void {
if (contexto === undefined || ancora === undefined || congelado) {
return;
}
// O conjunto de âncoras rastreadas é opcional na especificação. Quando ele
// vem e a nossa não está lá, o aparelho descartou a âncora — costuma
// acontecer quando o mapa é refeito depois de uma perda longa. Tratar isso
// como "pose indisponível" faria a bancada esperar para sempre por uma
// resposta que não vem mais.
const rastreadas: XRAnchorSet | undefined = contexto.quadro.trackedAnchors;
if (rastreadas !== undefined && !rastreadas.has(ancora)) {
estadoAtual = 'âncora perdida';
ancora = undefined;
return;
}
const pose: XRPose | undefined = contexto.quadro.getPose(ancora.anchorSpace, contexto.espaco);
if (pose === undefined) {
// Ausência de pose em um quadro é normal e passageira: o aparelho ainda
// não relocalizou aquele ponto. A bancada fica onde está, que é o
// comportamento certo — corrigir para uma posição inventada seria pior
// que não corrigir.
quadrosSemPoseDaAncora += 1;
return;
}
posicaoAgora.copy(pousa.position);
assentarNoEspacoLocal(pose.transform.matrix, raiz, pousa);
correcaoAcumuladaM += posicaoAgora.distanceTo(pousa.position);
estadoAtual = 'ancorada';
},
congelar(valor: boolean): void {
congelado = valor;
},
soltar(): void {
ancora = undefined;
estadoAtual = 'sem pouso';
congelado = false;
porQueEstatico = '';
correcaoAcumuladaM = 0;
quadrosSemPoseDaAncora = 0;
pousa.position.copy(posicaoDeOrigem);
pousa.quaternion.identity();
},
pousada(): boolean {
return estadoAtual !== 'sem pouso';
},
estado(): EstadoDaAncora {
return estadoAtual;
},
linhas(): string[] {
const linhas: string[] = [`Registro da bancada: ${estadoAtual}.`];
switch (estadoAtual) {
case 'sem pouso':
linhas.push(
'Mire uma superfície e acione para pousar. Enquanto não houver pouso, a bancada ' +
'está onde o modelador a deixou, e não onde a sala a admite.',
);
break;
case 'ancorada':
linhas.push(
`A plataforma já corrigiu ${(correcaoAcumuladaM * 100).toFixed(1)} cm desde o pouso.`,
'Esse número é o que a bancada teria escorregado sobre a mesa sem âncora nenhuma. ' +
'Ande em volta dela e veja-o crescer.',
`Quadros em que a âncora não teve pose: ${quadrosSemPoseDaAncora}.`,
);
break;
case 'registro estático':
linhas.push(
`Por quê: ${porQueEstatico}.`,
'A bancada foi escrita uma vez e ninguém mais a corrige. Ela vai derivar, e a deriva ' +
'aparece em minutos — é a mesma demonstração, com o resultado que se quer contrastar.',
);
break;
case 'âncora perdida':
linhas.push(
'O aparelho descartou a âncora, provavelmente ao refazer o mapa depois de uma perda ' +
'longa. A bancada ficou onde estava; pousar de novo é o caminho, e é uma decisão de ' +
'quem usa, não do ambiente.',
);
break;
}
return linhas;
},
};
}Repare em quem escreve a posição da bancada. É a única coisa deste percurso inteiro cuja posição é escrita por outra pessoa, e o número que o arquivo acumula é a evidência disso: quantos centímetros a plataforma já corrigiu desde o pouso. Numa volta em torno da mesa, ele costuma passar de um centímetro.
Essa medida é o argumento da tarefa, e ela responde à pergunta que o aluno faz. A âncora parece burocracia até alguém ver o número crescer, porque ele é exatamente a distância que a bancada teria escorregado sem ela.
O caminho de degradação está escrito e não é silencioso. Onde a âncora não existe, porque o aparelho não a concedeu, a bancada é registrada uma vez e fica onde caiu. Funciona por alguns minutos e depois deriva — e a comparação entre os dois casos, com o mesmo código e os mesmos gestos, é a melhor aula do módulo.
A criação da âncora nasce do próprio resultado da consulta, e não de uma pose recopiada por nós. A diferença importa: o resultado carrega a qual parte do mapa aquele ponto pertence, e é esse vínculo que a âncora preserva. Recriar a pose à mão produz uma âncora presa a um ponto solto do espaço, que é quase a mesma coisa.
1.5 Tarefa 3: Definir o comportamento sob perda de rastreamento
O caso que a demonstração de laboratório nunca mostra, e que o uso real produz em minutos.
Enunciado da tarefa
Deriva e perda de referência acontecem em uso real, e o que o ambiente faz nesse instante é parte do projeto, não acidente a esconder.
O que fica pronto é o comportamento definido e visível para a perda e para a recuperação: o que se mostra, o que se congela, o que se pede a quem está usando. Ambiente que desaparece sem explicação transforma uma limitação conhecida da tecnologia em defeito aparente do projeto.
1.5.1 Três decisões, e as três têm uma alternativa pior
Quanto tempo leva, numa sala de aula, até alguém tapar a câmera com a mão? Menos do que uma demonstração inteira. Alguém apaga a luz. Alguém se vira de frente para uma parede branca sem textura, ou anda rápido demais. O cartógrafo perde o desenho da sala, e com ele a resposta para onde eu estou.
A plataforma já fez a parte dela: parou de entregar pose, e não mentiu. O que sobra é decisão de projeto, e ela é visível para qualquer pessoa que use o ambiente — o que a torna parte da entrega.
src/bancada/anchoring/rastreamento.ts
// ---------------------------------------------------------------------------
// O que o ambiente faz quando o aparelho deixa de saber onde está.
//
// Este arquivo trata do caso que a demonstração de laboratório nunca mostra e
// que o uso real produz em minutos: alguém tapa a câmera com a mão, apaga a luz,
// vira-se de frente para uma parede branca sem textura, ou simplesmente anda
// rápido demais. O cartógrafo perde o desenho da sala, e com ele a resposta para
// "onde eu estou".
//
// O que se decide aqui não é técnico, é de projeto. A plataforma já fez a parte
// dela: parou de entregar pose, e não mente. O que sobra é escolher o que se
// mostra, o que se congela e o que se pede a quem está usando — e essa escolha é
// visível para qualquer pessoa que use o ambiente, o que a torna parte da
// entrega, e não detalhe de implementação.
//
// Três decisões, e as três têm uma alternativa comum que é pior.
//
// CONGELA-SE, em vez de continuar. Sem pose, qualquer atualização da cena usaria
// a última posição conhecida como se fosse a atual — e a bancada nadaria pela
// sala enquanto a pessoa anda. Congelada, ela fica errada de um jeito honesto:
// parada, no lugar em que o registro ainda valia.
//
// AVISA-SE PRESO À CABEÇA, e esta é a única exceção do percurso à regra de que
// cartaz não se prende ao rosto. A regra existe porque texto grudado no campo de
// visão desconforta; a exceção existe porque, sem rastreamento, não há mundo onde
// pendurar o aviso — um cartaz preso à bancada, que acabou de deixar de estar no
// lugar certo, é exatamente o lugar onde ninguém vai ler.
//
// PEDE-SE UMA AÇÃO CONCRETA, e não paciência. "Rastreamento perdido" não diz o
// que fazer; "olhe em volta, para um canto com objetos" diz — e é o que de fato
// recupera, porque devolve ao aparelho a textura de que ele precisa para
// reconhecer onde está.
// ---------------------------------------------------------------------------
import { Object3D, PerspectiveCamera, Quaternion, Vector3 } from 'three';
import { montarPainel, type Painel } from '../ui/painel';
import type { QuadroDaSessao } from './espaco';
/** Em que situação está a informação de posição que sustenta o registro. */
export type EstadoDoRastreamento = 'rastreando' | 'perdido' | 'recuperado';
/** A que distância do rosto o aviso é posto, em metros. */
const DISTANCIA_DO_AVISO_M: number = 0.9;
/**
* Fator de redução do cartaz.
*
* O painel do orçamento tem tamanho de cartaz de bancada, pensado para ser lido
* a um metro e meio. A noventa centímetros do rosto, em tamanho cheio, ele ocupa
* o campo de visão inteiro — e um aviso que tapa o mundo é uma segunda falha
* empilhada sobre a primeira.
*/
const REDUCAO_DO_AVISO: number = 0.45;
/** Por quanto tempo o aviso de recuperação permanece, em segundos. */
const PERMANENCIA_DO_AVISO_S: number = 3;
export interface Rastreamento {
atualizar(
contexto: QuadroDaSessao | undefined,
camera: PerspectiveCamera,
decorrido: number,
): void;
perdido(): boolean;
estado(): EstadoDoRastreamento;
/** Zera o estado. A sessão terminou, e o que se sabia dela não vale mais. */
reiniciar(): void;
linhas(): string[];
}
const frenteDaCabeca: Vector3 = new Vector3();
const posicaoDaCabeca: Vector3 = new Vector3();
const giroDaCabeca: Quaternion = new Quaternion();
const giroDaRaiz: Quaternion = new Quaternion();
export function montarRastreamento(raiz: Object3D): Rastreamento {
const cartaz: Painel = montarPainel('Registro suspenso');
cartaz.no.scale.setScalar(REDUCAO_DO_AVISO);
cartaz.no.visible = false;
raiz.add(cartaz.no);
let estadoAtual: EstadoDoRastreamento = 'rastreando';
let perdasNaSessao: number = 0;
let instanteDaPerda: number = 0;
let instanteDaRecuperacao: number = 0;
let maiorPerdaS: number = 0;
let ultimaCausa: string = 'nenhuma perda nesta sessão';
function posicionarDiante(camera: PerspectiveCamera): void {
// A posição vem da matriz de mundo da câmera, e não da posição declarada
// dela: dentro da sessão quem escreve a câmera é o aparelho, e é a matriz que
// carrega o resultado. Ler a posição declarada devolveria o ponto em que a
// órbita a deixou, no desktop, antes de a sessão abrir.
camera.getWorldPosition(posicaoDaCabeca);
camera.getWorldDirection(frenteDaCabeca);
cartaz.no.position
.copy(posicaoDaCabeca)
.addScaledVector(frenteDaCabeca, DISTANCIA_DO_AVISO_M);
raiz.worldToLocal(cartaz.no.position);
// A orientação também precisa sair do espaço de mundo para o espaço da raiz.
// A raiz deste projeto não gira — o assentamento é só uma descida —, e por
// isso a conta abaixo devolve hoje o mesmo que copiar o giro direto. Ela
// está escrita mesmo assim: no dia em que alguém girar a raiz para alinhar a
// bancada com uma parede, o cartaz continuaria de frente para o rosto por
// acidente, e o defeito não teria sintoma até esse dia.
camera.getWorldQuaternion(giroDaCabeca);
raiz.getWorldQuaternion(giroDaRaiz);
cartaz.no.quaternion.copy(giroDaRaiz.invert().multiply(giroDaCabeca));
}
return {
atualizar(
contexto: QuadroDaSessao | undefined,
camera: PerspectiveCamera,
decorrido: number,
): void {
if (contexto === undefined) {
cartaz.no.visible = false;
estadoAtual = 'rastreando';
return;
}
// Duas fontes de perda, e elas são diferentes. A pose ausente é o mapa
// perdido — a câmera tapada, a parede lisa, o escuro. A visibilidade
// diferente de visível é a sessão em segundo plano: quem tirou o visor da
// cabeça, quem abriu o menu do sistema. As duas suspendem o registro, e a
// segunda não é falha nenhuma; separá-las na causa evita ensinar que o
// ambiente quebrou quando alguém apenas coçou a testa.
const pose: XRViewerPose | undefined = contexto.quadro.getViewerPose(contexto.espaco);
const emPrimeiroPlano: boolean = contexto.sessao.visibilityState === 'visible';
const temRegistro: boolean = pose !== undefined && emPrimeiroPlano;
if (!temRegistro && estadoAtual !== 'perdido') {
estadoAtual = 'perdido';
perdasNaSessao += 1;
instanteDaPerda = decorrido;
ultimaCausa =
pose === undefined
? 'o aparelho parou de informar a pose da cabeça — mapa perdido'
: `a sessão saiu do primeiro plano (${contexto.sessao.visibilityState})`;
} else if (temRegistro && estadoAtual === 'perdido') {
estadoAtual = 'recuperado';
instanteDaRecuperacao = decorrido;
maiorPerdaS = Math.max(maiorPerdaS, decorrido - instanteDaPerda);
} else if (
estadoAtual === 'recuperado' &&
decorrido - instanteDaRecuperacao > PERMANENCIA_DO_AVISO_S
) {
// O aviso de recuperação não some no instante em que o registro volta. Ele
// permanece alguns segundos de propósito: quem estava olhando para o lado
// no momento da perda precisa saber que a bancada esteve congelada, ou vai
// atribuir ao próprio gesto o objeto que não respondeu.
estadoAtual = 'rastreando';
}
if (estadoAtual === 'rastreando') {
cartaz.no.visible = false;
return;
}
posicionarDiante(camera);
cartaz.no.visible = true;
cartaz.atualizar(
estadoAtual === 'perdido'
? [
'O aparelho perdeu a',
'referência do ambiente.',
'A bancada está congelada',
'onde o registro valia.',
'Olhe em volta, para um',
'canto com objetos.',
]
: [
'Referência recuperada.',
'A bancada voltou a ser',
'corrigida pela âncora.',
],
decorrido,
);
},
perdido(): boolean {
return estadoAtual === 'perdido';
},
estado(): EstadoDoRastreamento {
return estadoAtual;
},
reiniciar(): void {
estadoAtual = 'rastreando';
cartaz.no.visible = false;
perdasNaSessao = 0;
maiorPerdaS = 0;
ultimaCausa = 'nenhuma perda nesta sessão';
},
linhas(): string[] {
return [
`Estado do registro: ${estadoAtual}.`,
`Perdas nesta sessão: ${perdasNaSessao}. Mais longa: ${maiorPerdaS.toFixed(1)} s.`,
`Última causa: ${ultimaCausa}.`,
'Enquanto perdido, a manipulação e a locomoção ficam suspensas: comandar um mundo cuja ' +
'posição não se conhece produz encaixe no lugar errado, e o erro sobrevive à recuperação.',
'Provocação de bancada, e a única que não precisa de código: tape a câmera do aparelho com ' +
'a mão por três segundos. É o mesmo caminho que a sala escura percorre sozinha.',
];
},
};
}Congela-se, em vez de continuar. Sem pose, qualquer atualização usaria a última posição conhecida como se fosse a atual, e a bancada nadaria pela sala enquanto a pessoa anda. Congelada, ela fica errada de um jeito honesto: parada, no lugar em que o registro ainda valia.
Avisa-se preso à cabeça, e esta é a única exceção do percurso à regra que proibiu cartaz grudado no rosto. A regra existe porque texto no campo de visão desconforta. A exceção existe porque, sem rastreamento, não há mundo onde pendurar o aviso — e um cartaz preso à bancada, que acabou de deixar de estar no lugar certo, é onde ninguém vai lê-lo.
Pede-se uma ação concreta, e não paciência. “Rastreamento perdido” não diz o que fazer. “Olhe em volta, para um canto com objetos” diz, e é o que de fato recupera: devolve ao aparelho a textura de que ele precisa para reconhecer onde está.
O arquivo separa duas causas que se parecem e não são a mesma. A pose ausente é o mapa perdido. A sessão fora do primeiro plano é quem tirou o visor da cabeça ou abriu o menu do sistema — e isso não é falha nenhuma. Confundi-las ensinaria que o ambiente quebrou quando alguém apenas coçou a testa.
O aviso de recuperação não some no instante em que o registro volta. Ele permanece alguns segundos, de propósito. Quem estava olhando para o lado no momento da perda precisa saber que a bancada esteve congelada, ou vai atribuir ao próprio gesto o objeto que não respondeu.
1.6 A composição, e o que ela acrescentou
O recibo do módulo é a lista de arquivos que não mudaram, e ela é a mesma de três módulos atrás.
Quantos arquivos mudaram fora das pastas novas? Um: a composição. Ela registra o terceiro plugue em uma linha, monta os quatro objetos da ancoragem, liga o par de avisos do ciclo de sessão e acrescenta quatro chamadas no laço. Nenhuma delas está na camada de interação.
A ordem dessas chamadas dentro do quadro tem consequência, e ela é a mesma lição da locomoção. A ancoragem é atualizada antes de tudo o que lê posição, porque a âncora move a bancada e a mira mede distâncias contra peças que pendem dela. Atualizar depois deixaria a interação um quadro atrás do mundo real, e a peça escaparia da mão de quem caminha.
Uma regra de ambiente mora ali e em nenhum outro lugar: o primeiro acionamento em sessão aumentada pousa, e os seguintes manipulam. Enquanto a bancada não tem lugar na sala, escolher uma peça dela é escolher um objeto que não está em parte alguma. Depois de pousada, o mesmo gesto volta a ser o de sempre.
E o congelamento sob perda é implementado como um retorno antecipado do laço. O que ele custa é tudo o que deixa de rodar abaixo dele: seleção, agarre, encaixe e locomoção. O que continua rodando é o que não depende de posição — o eixo segue girando, a animação do ativo segue andando. Congelar a cena inteira faria a perda de referência parecer travamento do programa, que é a leitura errada e a mais assustadora das duas.
1.7 O que este módulo deixa pronto
Três formas de apontar, uma lógica só, e agora sobre uma mesa que existe.
A Bancada roda nos três regimes previstos desde o primeiro módulo. O aluno que abre a mesma URL no computador, no visor e no celular encontra o mesmo ambiente, e o painel de capacidades explica a diferença entre os três aparelhos sem que ninguém precise defendê-la.
E como se sabe que isto não é uma demonstração vazia? A verificação leva segundos e não pede instrumento nenhum. Pouse a bancada na mesa e ande em volta dela. Se ela acompanhar a tela, a câmera virou papel de parede; se ficar onde foi posta, há registro.
Fica pendente o registro por marcador impresso, que é o assunto do próximo trecho. Ele existe porque a consulta de interseção depende de uma capacidade que boa parte dos aparelhos da turma não tem — e um percurso em que só três pessoas conseguem executar realidade aumentada não é um percurso.