1 Locomoção em ambientes imersivos — 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.” Este é o módulo em que a tomada encontra alguma coisa que não cabe nela.
A sala do laboratório tem uns poucos metros livres. A oficina que construímos é maior que isso, e vai crescer. Alguém de pé no meio da sala precisa alcançar a prateleira do fundo, e não há chão real para andar até lá.
Esse é o problema inteiro, e ele não se resolve com mais rastreamento. Resolve-se decidindo o que acontece quando quem observa pede para estar noutro lugar sem ter caminhado até ele. A área inteira demorou anos a chegar na resposta, e a resposta contraria o instinto de qualquer projetista de jogos.
Entram sete arquivos, todos numa pasta nova. Nenhum deles fica na camada de interação, e essa ausência é o recibo do módulo — a mesma prova que o controle rastreado já tinha pagado dois módulos atrás.
1.2 A tentação, e por que ela é forte
O caminho de entrada paralelo é o defeito que este módulo quase escreve sozinho.
Locomoção só existe no regime imersivo. Ela não tem contraparte na janela, não aparece no celular apoiado na mesa, e é comandada por uma peça de hardware — o manche do controle — que nenhuma outra parte do projeto tocou até agora.
Tudo nisso convida a escrever código específico do controle. Um teleporte que leia o aparelho direto funciona na primeira tarde, não quebra nada e não acusa erro em lugar nenhum. A conta chega no módulo aumentado, quando a terceira fonte entrar e descobrir que existe um caminho de entrada que ela não alimenta.
A regra que adotamos é curta: a mira vem da tomada. O arco do salto é calculado a partir de uma leitura de apontamento, exatamente como a seleção e o agarre. Quem estiver do outro lado — controle, mouse ou dedo — é assunto que este módulo não tem como investigar.
1.3 Tarefa 1: Deslocar sem mover o corpo
A solução que se firmou na área abriu mão do movimento, e entender por que é mais útil que memorizar a lista de técnicas.
Enunciado da tarefa
Mover alguém por um mundo virtual sem que o corpo se mova de verdade é o problema que mais separou soluções boas de ruins nesta área, e a solução vencedora abriu mão do movimento contínuo.
O que fica pronto é o salto para um ponto mirado, com arco de mira que mostra onde se vai parar, mais o giro em passos discretos. Os dois precisam funcionar com a mesma abstração de apontar já escrita, e não com um caminho de entrada próprio.
1.3.1 A parábola, e por que não um raio
Para onde eu vou? A pergunta parece pedir um raio reto, e o raio reto responde mal. Apontado um pouco acima da horizontal, ele encontra o piso a dezenas de metros ou não o encontra nunca; apontado um pouco abaixo, encontra o piso a meio metro dos pés.
A faixa útil de inclinação fica estreita demais para um braço humano. Quem usa passa a mirar com o pulso, corrigindo em graus, e a mira vira exercício de pontaria em vez de gesto.
O arco troca isso por uma função suave. Ele é a trajetória de uma pedra atirada na direção em que o braço aponta, com a gravidade puxando por baixo, e a distância cresce devagar com a inclinação. O alcance tem um teto natural que nada precisa impor.
src/bancada/locomotion/arco.ts
// ---------------------------------------------------------------------------
// A mira do salto: uma parábola, e não um raio.
//
// A pergunta que abre o módulo é simples de enunciar e cara de errar: para onde
// eu vou? Um raio reto responde mal. Apontado para a frente, ele encontra o piso
// muito longe ou não o encontra nunca; apontado para baixo, encontra o piso a
// meio metro dos pés. A faixa útil de inclinação fica estreita, e quem usa passa
// a mirar com o pulso em vez de com o braço.
//
// O arco resolve isso porque a distância vira função suave da inclinação. Ele é
// a trajetória de uma pedra atirada: a mesma parábola da queda livre, com uma
// velocidade inicial na direção em que o braço aponta. Inclinar um pouco mais
// alonga o salto, inclinar um pouco menos o encurta, e o alcance tem um teto
// natural que nada precisa impor.
//
// Duas decisões deste arquivo merecem ser lidas antes do código.
//
// A primeira é de onde vem a direção: da abstração de apontar, e de mais nada.
// Este arquivo não conhece controle, mouse nem dedo. É a exigência do módulo, e
// ela não é estética — um caminho de entrada próprio para a locomoção quebraria
// em silêncio a economia construída dois módulos atrás, e a conta só apareceria
// no módulo aumentado, onde a terceira fonte entra.
//
// A segunda é o espaço em que a conta acontece. O piso é y = 0 no espaço LOCAL
// da raiz da cena, e não no espaço de mundo: o módulo anterior desce o mundo
// inteiro quando o aparelho não concede o piso real, e mirar contra y = 0 de
// mundo faria o salto pousar na altura dos olhos de quem entrou. O ponteiro
// chega em coordenadas de mundo, e a conversão é a primeira coisa que se faz.
// ---------------------------------------------------------------------------
import {
BufferGeometry,
Line,
LineBasicMaterial,
Matrix4,
Mesh,
MeshBasicMaterial,
Object3D,
RingGeometry,
Vector3,
} from 'three';
import type { Ponteiro } from '../input/ponteiro';
/**
* Velocidade inicial do arco, em metros por segundo.
*
* Não é a velocidade de nada que se mova: o salto é instantâneo, e este número
* só existe para dar forma à parábola. Seis metros por segundo, com a gravidade
* abaixo, põem o alcance máximo em pouco mais de três metros e meio — a medida
* de uma sala de trabalho, que é onde esta oficina vive.
*/
const VELOCIDADE_M_S: number = 6;
/** Aceleração da queda, em metros por segundo ao quadrado. */
const GRAVIDADE_M_S2: number = 9.8;
/** Passo de tempo da amostragem da parábola, em segundos. */
const PASSO_S: number = 1 / 30;
/** Quantos passos no máximo. Trinta cobrem um segundo de voo e sobram. */
const PASSOS_MAXIMOS: number = 30;
/** Raio do disco que marca a chegada, em metros. Meio passo de pessoa. */
const RAIO_DO_DISCO: number = 0.25;
/** O que a mira encontrou neste quadro. */
export interface Mira {
/** Verdadeiro quando a parábola cruzou o piso dentro do trecho amostrado. */
readonly encontrou: boolean;
/** O ponto de chegada, no espaço LOCAL da raiz da cena. */
readonly destino: Vector3;
/** Distância horizontal entre quem mira e o destino, em metros. */
readonly distanciaM: number;
}
export interface Arco {
/** Recalcula a parábola a partir de uma leitura de apontamento. */
mirar(ponteiro: Ponteiro): Mira;
/** Liga e desliga o desenho do arco e do disco. */
exibir(visivel: boolean): void;
/** A última mira calculada. */
ultima(): Mira;
linhas(): string[];
encerrar(): void;
}
const origemLocal: Vector3 = new Vector3();
const direcaoLocal: Vector3 = new Vector3();
const ponto: Vector3 = new Vector3();
const anterior: Vector3 = new Vector3();
const inversaDaRaiz: Matrix4 = new Matrix4();
/**
* Monta o arco sob a raiz da cena.
*
* O nó de desenho é filho da raiz, e não da câmera nem do controle: a parábola
* pertence ao mundo, e prendê-la a quem mira faria a curva acompanhar a cabeça
* em vez de ficar onde o piso está.
*/
export function montarArco(raiz: Object3D): Arco {
const materialDaLinha: LineBasicMaterial = new LineBasicMaterial({ color: 0x6fe3a1 });
const geometriaDaLinha: BufferGeometry = new BufferGeometry().setFromPoints([
new Vector3(),
new Vector3(),
]);
const linha: Line = new Line(geometriaDaLinha, materialDaLinha);
linha.name = 'arco-de-mira';
linha.visible = false;
// A curva se refaz a cada quadro e sairia da caixa envolvente calculada no
// nascimento. Sem esta linha, ela desaparece assim que a mira se afasta do
// ponto em que a geometria foi criada, e o defeito parece intermitência do
// rastreamento.
linha.frustumCulled = false;
raiz.add(linha);
const materialDoDisco: MeshBasicMaterial = new MeshBasicMaterial({ color: 0x6fe3a1 });
const geometriaDoDisco: RingGeometry = new RingGeometry(RAIO_DO_DISCO * 0.6, RAIO_DO_DISCO, 24);
const disco: Mesh = new Mesh(geometriaDoDisco, materialDoDisco);
disco.name = 'disco-de-chegada';
// O anel nasce de pé, no plano vertical. Deitá-lo é o que o faz descrever o
// chão em vez de uma placa flutuante diante de quem mira.
disco.rotation.x = -Math.PI / 2;
disco.visible = false;
raiz.add(disco);
const pontos: Vector3[] = [];
let mira: Mira = { encontrou: false, destino: new Vector3(), distanciaM: 0 };
let visivel: boolean = false;
function desenhar(): void {
geometriaDaLinha.setFromPoints(pontos);
linha.visible = visivel && pontos.length > 1;
disco.visible = visivel && mira.encontrou;
if (mira.encontrou) {
// Um dedo acima do piso: o anel exatamente sobre zero disputa a
// profundidade com o chão e cintila, e a cintilação é lida como defeito de
// rastreamento por quem está de visor.
disco.position.set(mira.destino.x, mira.destino.y + 0.01, mira.destino.z);
}
}
return {
mirar(ponteiro: Ponteiro): Mira {
raiz.worldToLocal(origemLocal.copy(ponteiro.origem));
// A direção não se converte com a mesma conta do ponto: ela não tem
// posição, e aplicar a translação a ela produziria um vetor apontado para
// a origem da cena. A matriz inversa entra sem a coluna de deslocamento, e
// é isso que a transformação de direção faz.
inversaDaRaiz.copy(raiz.matrixWorld).invert();
direcaoLocal.copy(ponteiro.direcao).transformDirection(inversaDaRaiz).normalize();
pontos.length = 0;
anterior.copy(origemLocal);
pontos.push(anterior.clone());
let encontrou: boolean = false;
const destino: Vector3 = new Vector3().copy(origemLocal);
for (let passo: number = 1; passo <= PASSOS_MAXIMOS; passo += 1) {
const t: number = passo * PASSO_S;
ponto.copy(direcaoLocal).multiplyScalar(VELOCIDADE_M_S * t).add(origemLocal);
ponto.y = origemLocal.y + direcaoLocal.y * VELOCIDADE_M_S * t - 0.5 * GRAVIDADE_M_S2 * t * t;
if (ponto.y <= 0) {
// O cruzamento fica entre a amostra anterior e esta, e interpolar
// linearmente entre as duas basta: em um trigésimo de segundo a
// parábola é praticamente reta, e o erro fica muito abaixo do raio do
// disco que marca a chegada.
const fracao: number = anterior.y / (anterior.y - ponto.y);
destino.lerpVectors(anterior, ponto, fracao);
destino.y = 0;
pontos.push(destino.clone());
encontrou = true;
break;
}
pontos.push(ponto.clone());
anterior.copy(ponto);
}
const distanciaM: number = Math.hypot(destino.x - origemLocal.x, destino.z - origemLocal.z);
mira = { encontrou, destino, distanciaM };
desenhar();
return mira;
},
exibir(mostrar: boolean): void {
visivel = mostrar;
desenhar();
},
ultima(): Mira {
return mira;
},
linhas(): string[] {
return [
`Arco com velocidade inicial de ${VELOCIDADE_M_S} m/s sob gravidade de ` +
`${GRAVIDADE_M_S2} m/s ao quadrado, amostrado em ${PASSOS_MAXIMOS} passos de ` +
`${(PASSO_S * 1000).toFixed(0)} ms.`,
mira.encontrou
? `Mira sobre o piso a ${mira.distanciaM.toFixed(2)} m de quem aponta.`
: 'A parábola não alcançou o piso dentro do trecho amostrado: o braço está apontando ' +
'alto demais, e não há destino a confirmar.',
'A direção vem da abstração de apontar e de mais nada. Este arquivo não sabe se do outro ' +
'lado há um controle, um mouse ou um dedo.',
'A conta acontece no espaço local da raiz da cena, onde o piso é zero. Em coordenadas de ' +
'mundo ele deixa de sê-lo assim que o assentamento desce o mundo, e o salto passaria a ' +
'pousar na altura dos olhos.',
];
},
encerrar(): void {
raiz.remove(linha);
raiz.remove(disco);
geometriaDaLinha.dispose();
materialDaLinha.dispose();
geometriaDoDisco.dispose();
materialDoDisco.dispose();
},
};
}Repare em onde a conta acontece. O piso é zero no espaço local da raiz da cena, e não no espaço de mundo — porque o módulo anterior desce o mundo inteiro quando o aparelho não concede o piso real. Mirar contra o zero de mundo faria o salto pousar na altura dos olhos de quem entrou, num aparelho e não no outro.
O cruzamento com o piso é encontrado por interpolação entre duas amostras vizinhas. Em um trigésimo de segundo a parábola é praticamente reta, e o erro fica bem abaixo do raio do disco que marca a chegada. Precisão maior que a marca visível é precisão que ninguém consegue usar.
1.3.2 O eixo que não coube na tomada
A locomoção precisa de um valor que a abstração de apontar não carrega. Empurrar o manche para a frente abre a mira; inclinar para o lado gira. Nenhum dos dois é acionamento, agarre ou pose — é um eixo analógico, e a tomada não tem campo para ele.
A tentação, de novo, é alargar a tomada. Dois números a mais e segue o jogo. Não fizemos isso, e a razão é a mesma que manteve a pose como campo opcional: eixo analógico existe no controle e não existe no cursor nem no dedo. Posto na tomada, ele obrigaria as outras duas fontes a inventar um valor ou a devolver zero para sempre.
O manche fica ao lado da locomoção, e não dentro da tomada. Quem o consome é só quem se desloca. A camada de seleção continua sem ter como perguntar se este aparelho tem manche, que é exatamente o conhecimento que ela não pode ter.
src/bancada/locomotion/manche.ts
// ---------------------------------------------------------------------------
// O comando analógico, e a pergunta que ele obriga a responder.
//
// A tomada de apontar carrega origem, direção, acionamento, agarre, estado e
// pose. Ela não carrega eixo analógico, e a locomoção precisa de um: empurrar o
// manche para a frente é o que faz aparecer a mira do salto, e inclinar para o
// lado é o que gira. A tentação, aqui, é alargar a tomada — acrescentar dois
// números a ela e seguir em frente.
//
// Não alargamos, e a razão é a mesma que manteve a pose como campo opcional. Um
// eixo analógico existe no controle rastreado e não existe no cursor nem no
// dedo; posto na tomada, ele obrigaria as outras duas fontes a inventar um valor
// ou a devolver zero para sempre. Pior: a camada de seleção passaria a receber,
// em toda leitura, dois campos que nada nela usa.
//
// O manche fica, portanto, ao lado da locomoção e não dentro da tomada. Quem o
// consome é só quem se desloca — o salto e o giro —, e a seleção continua sem
// ter como perguntar se existe manche neste aparelho.
//
// A segunda decisão é a do regime em janela. Ali não há manche, e o mesmo par de
// valores é produzido pelas setas do teclado. O teclado não aparece neste
// arquivo: quem o conhece é a composição, que já tinha licença para isso desde o
// giro da peça, e ela injeta o par aqui. Esta camada não sabe se o que chegou
// veio de um polegar ou de uma tecla.
// ---------------------------------------------------------------------------
import type { WebGLRenderer } from 'three';
/**
* Índices do par analógico principal no vetor de eixos do controle.
*
* O perfil de controle XR padrão põe o manche em quarto e quinto lugar da lista;
* os dois primeiros são a superfície sensível ao toque, que nem todo aparelho
* tem. Ler os dois primeiros funciona em um aparelho e devolve zero constante em
* outro, e o sintoma — "a locomoção não responde neste visor" — não aponta para
* cá em momento nenhum.
*/
const EIXO_X: number = 2;
const EIXO_Y: number = 3;
/**
* A partir de que inclinação o manche conta como empurrado.
*
* Manche em repouso não devolve zero exato: devolve alguma coisa pequena que
* varia entre aparelhos e ao longo do uso. Sem esta zona morta, o giro discreto
* dispararia sozinho com o controle pousado na mesa.
*/
export const ZONA_MORTA: number = 0.5;
/**
* Abaixo de que inclinação o manche volta a estar disponível para outro passo.
*
* As duas soleiras são diferentes de propósito. Com uma só, a inclinação que
* oscila em torno dela dispara uma sequência de giros, e quem está no visor
* roda meia volta por ter hesitado com o polegar. Voltar a armar exige soltar de
* verdade, e é isso que o número mais baixo cobra.
*/
export const SOLEIRA_DE_REARME: number = 0.25;
/** Uma leitura do comando analógico, já normalizada. */
export interface LeituraDoManche {
/** Negativo à esquerda, positivo à direita. */
readonly x: number;
/** Positivo para a frente, negativo para trás. */
readonly y: number;
/** De onde veio esta leitura, para o relatório e para depuração. */
readonly origem: 'manche' | 'teclado' | 'nenhuma';
}
export interface Manche {
/** Lê o quadro corrente. Chamado uma vez por quadro, como as fontes de apontar. */
ler(): LeituraDoManche;
/**
* Escreve o par de valores de fora, para o regime em janela.
*
* A composição chama isto com o estado das setas. Enquanto a sessão estiver
* aberta, o valor injetado é ignorado: o polegar de quem está com o visor no
* rosto manda mais que uma tecla apertada por engano no outro cômodo.
*/
injetar(x: number, y: number): void;
linhas(): string[];
}
/**
* Instala o manche sobre o renderizador.
*
* A leitura vem da sessão, e não de um evento: manche não emite evento ao ser
* empurrado, ele simplesmente está em alguma posição a cada quadro. É a diferença
* entre o gatilho, que avisa, e o eixo, que se consulta.
*/
export function montarManche(renderer: WebGLRenderer): Manche {
let injetadoX: number = 0;
let injetadoY: number = 0;
let ultima: LeituraDoManche = { x: 0, y: 0, origem: 'nenhuma' };
function lerDoControle(): LeituraDoManche | undefined {
const sessao: XRSession | null = renderer.xr.getSession();
if (sessao === null) {
return undefined;
}
for (const fonte of sessao.inputSources) {
const almofada: Gamepad | undefined = fonte.gamepad;
if (almofada === undefined) {
continue;
}
const x: number = almofada.axes[EIXO_X] ?? 0;
const y: number = almofada.axes[EIXO_Y] ?? 0;
if (Math.abs(x) < SOLEIRA_DE_REARME && Math.abs(y) < SOLEIRA_DE_REARME) {
continue;
}
// O sinal do eixo vertical é invertido na leitura, e não em quem consome.
// O aparelho devolve valor negativo quando o polegar vai para a frente,
// que é a convenção de tela e não a de mundo. Corrigir aqui deixa "para a
// frente é positivo" valendo em toda linha daqui para cima.
return { x, y: -y, origem: 'manche' };
}
return { x: 0, y: 0, origem: 'manche' };
}
return {
ler(): LeituraDoManche {
const doControle: LeituraDoManche | undefined = lerDoControle();
ultima =
doControle === undefined
? { x: injetadoX, y: injetadoY, origem: injetadoX === 0 && injetadoY === 0 ? 'nenhuma' : 'teclado' }
: doControle;
return ultima;
},
injetar(x: number, y: number): void {
injetadoX = x;
injetadoY = y;
},
linhas(): string[] {
return [
`Comando analógico: x ${ultima.x.toFixed(2)}, y ${ultima.y.toFixed(2)}, origem ${ultima.origem}.`,
`Zona morta em ${ZONA_MORTA.toFixed(2)} e rearme em ${SOLEIRA_DE_REARME.toFixed(2)}: as duas ` +
'soleiras são diferentes para que a inclinação que oscila não dispare uma sequência de passos.',
'O eixo analógico NÃO entrou na abstração de apontar. Ele existe no controle rastreado e não ' +
'existe no cursor nem no dedo, e pô-lo lá obrigaria as outras fontes a inventar um valor.',
];
},
};
}Duas soleiras diferentes governam o mesmo eixo, e a diferença entre elas não é descuido. Com uma soleira só, a inclinação que oscila em torno dela dispara uma sequência de giros — e quem está no visor roda meia volta por ter hesitado com o polegar. Voltar a armar exige soltar de verdade.
O índice do par analógico é o quarto e o quinto da lista, e não o primeiro e o segundo. Os dois primeiros são a superfície sensível ao toque, que nem todo aparelho tem. Ler o par errado funciona num visor e devolve zero constante no outro, e o sintoma que chega — “a locomoção não responde neste aparelho” — não aponta para o código em momento algum.
1.3.3 Quem se move quando ninguém anda
Na janela, deslocar-se é mudar para onde a câmera olha. No visor não há câmera a mover: há uma pessoa de pé numa sala física, e escrever a posição dela seria mentir sobre onde ela está.
O que se move é a origem do espaço de referência. O mundo desliza sob a pessoa, e a pose que o aparelho reporta continua sendo a verdade sobre o corpo. A lição já apareceu no ciclo de sessão, quando a origem foi deslocada para o posto de trabalho; a diferença é que ali o deslocamento acontecia uma vez, e aqui acontece a cada salto.
src/bancada/locomotion/transporte.ts
// ---------------------------------------------------------------------------
// Quem move o ponto de vista, e por que isso não é uma coisa só.
//
// Deslocar-se, no regime em janela, é mudar para onde a câmera olha. Deslocar-se
// no regime imersivo é outra operação inteiramente: não há câmera a mover,
// porque a câmera é a cabeça de uma pessoa que está de pé numa sala física, e
// escrever a posição dela seria mentir sobre onde ela está. O que se move é a
// ORIGEM DO ESPAÇO DE REFERÊNCIA — o mundo desliza sob a pessoa, e a pose que o
// aparelho reporta continua sendo a verdade sobre o corpo.
//
// A mesma lição já apareceu no ciclo de sessão, quando a origem foi deslocada
// para o posto de trabalho. A diferença é que ali o deslocamento acontecia uma
// vez, na entrada, e aqui ele acontece a cada salto — o que obriga a compor.
//
// O deslocamento é sempre relativo ao espaço CORRENTE, e não ao original. Cada
// salto é aplicado sobre o resultado do anterior, e é por isso que este arquivo
// guarda o acumulado: sem ele não há como relatar quanto se andou, nem como
// devolver o ambiente ao ponto de partida.
//
// A conta do sinal é a mesma que já se errou uma vez. O deslocamento recebido
// diz onde fica a origem do espaço NOVO dentro do espaço atual; as poses lidas
// no novo saem com o deslocamento invertido. Para que a pessoa APAREÇA um metro
// adiante, a origem nova vai um metro para trás.
//
// Uma suposição está escrita no código e merece ser dita em voz alta: as poses
// da sessão chegam no espaço de mundo da biblioteca, porque a câmera não tem pai
// na cena. Espaço de mundo e espaço de referência coincidem, e é isso que
// permite usar a posição de mundo da câmera como posição da cabeça.
// ---------------------------------------------------------------------------
import { Object3D, Quaternion, Vector3, type WebGLRenderer } from 'three';
import type { Orbita } from '../core/orbita';
/** Altura em que a órbita passa a olhar depois de um salto, em metros. */
const ALTURA_DO_ALVO_M: number = 0.95;
/**
* O que move o ponto de vista neste regime.
*
* As três operações devolvem um booleano em vez de nada, e não é zelo: no regime
* em janela o giro discreto não se aplica, e devolver falso é como o projeto diz
* isso sem que a camada de cima precise perguntar em que regime está.
*/
export interface Transporte {
readonly nome: string;
/** Leva o ponto de vista ao destino, dado no espaço local da raiz da cena. */
saltarPara(destinoLocal: Vector3): boolean;
/** Desloca o ponto de vista por um vetor horizontal, em metros. */
deslizar(deltaMundo: Vector3): boolean;
/** Gira o ponto de vista em torno do próprio eixo vertical de quem observa. */
girar(graus: number): boolean;
/** Devolve o ambiente ao ponto de partida. */
reiniciar(): void;
linhas(): string[];
}
const destinoMundo: Vector3 = new Vector3();
const cabeca: Vector3 = new Vector3();
const deslocamento: Vector3 = new Vector3();
const giroEmY: Quaternion = new Quaternion();
const eixoVertical: Vector3 = new Vector3(0, 1, 0);
/**
* O transporte do regime imersivo: desloca a origem do espaço de referência.
*
* Recebe a raiz da cena para converter o destino, que vem em coordenadas locais,
* e a câmera para saber onde a cabeça está — porque tanto o salto quanto o giro
* são medidos a partir de quem observa, e não a partir da origem.
*/
export function transporteDaSessao(
renderer: WebGLRenderer,
raiz: Object3D,
camera: Object3D,
): Transporte {
let acumuladoM: number = 0;
let saltos: number = 0;
let girosGraus: number = 0;
/**
* Compõe um deslocamento sobre o espaço de referência corrente.
*
* O quatérnio recebido descreve a rotação que o mundo sofre. A posição
* recebida é onde a origem nova fica dentro do espaço atual, já com o sinal
* invertido por quem chama.
*/
function compor(posicao: Vector3, rotacao: Quaternion): boolean {
const base: XRReferenceSpace | null = renderer.xr.getReferenceSpace();
if (base === null) {
return false;
}
const novo: XRReferenceSpace = base.getOffsetReferenceSpace(
new XRRigidTransform(
{ x: posicao.x, y: posicao.y, z: posicao.z },
{ x: rotacao.x, y: rotacao.y, z: rotacao.z, w: rotacao.w },
),
);
renderer.xr.setReferenceSpace(novo);
return true;
}
return {
nome: 'origem do espaço de referência',
saltarPara(destinoLocal: Vector3): boolean {
if (!renderer.xr.isPresenting) {
return false;
}
raiz.localToWorld(destinoMundo.copy(destinoLocal));
camera.getWorldPosition(cabeca);
// Só o plano horizontal. Deslocar em altura levaria a pessoa a flutuar, e
// a altura de quem está de pé é assunto do aparelho, não deste arquivo.
deslocamento.set(destinoMundo.x - cabeca.x, 0, destinoMundo.z - cabeca.z);
// O sinal invertido: para a pessoa aparecer adiante, a origem recua.
if (!compor(deslocamento.clone().negate(), new Quaternion())) {
return false;
}
acumuladoM += deslocamento.length();
saltos += 1;
return true;
},
deslizar(deltaMundo: Vector3): boolean {
if (!renderer.xr.isPresenting) {
return false;
}
if (!compor(deltaMundo.clone().negate(), new Quaternion())) {
return false;
}
acumuladoM += deltaMundo.length();
return true;
},
girar(graus: number): boolean {
if (!renderer.xr.isPresenting) {
return false;
}
camera.getWorldPosition(cabeca);
const radianos: number = (graus * Math.PI) / 180;
giroEmY.setFromAxisAngle(eixoVertical, radianos);
// Girar em torno da origem faria quem observa descrever um arco em vez de
// virar no lugar: a poucos metros da origem, um passo de trinta graus
// moveria a pessoa mais de um metro de lado. O pivô é a cabeça, e a conta
// que traduz isso para um deslocamento é levar o pivô à origem, girar e
// trazer de volta.
const pivo: Vector3 = new Vector3(cabeca.x, 0, cabeca.z);
const posicao: Vector3 = pivo.clone().sub(pivo.clone().applyQuaternion(giroEmY));
if (!compor(posicao, giroEmY)) {
return false;
}
girosGraus += graus;
return true;
},
reiniciar(): void {
// Não há como desfazer composições uma a uma sem guardar a pilha inteira, e
// guardá-la serviria a um caso só. O reinício aqui é o da CONTAGEM: o
// ambiente volta ao ponto de partida saindo e entrando de novo em sessão,
// que é o gesto que quem usa faz de qualquer maneira.
acumuladoM = 0;
saltos = 0;
girosGraus = 0;
},
linhas(): string[] {
return [
'Regime imersivo: quem se move é a origem do espaço de referência, e não a câmera. ' +
'A pose que o aparelho reporta continua sendo a verdade sobre o corpo de quem está de pé.',
`${saltos} saltos e ${girosGraus.toFixed(0)} graus de giro acumulados, somando ` +
`${acumuladoM.toFixed(2)} m percorridos sem que ninguém tenha andado esse tanto.`,
'Cada deslocamento é composto sobre o espaço corrente, e não sobre o original: é por ' +
'isso que o acumulado precisa ser guardado aqui.',
];
},
};
}
/**
* O transporte do regime em janela: leva o alvo da órbita.
*
* Existe para que o salto possa ser conferido no desktop, sem visor — o que
* importa quando há três aparelhos para quarenta pessoas. E existe também para
* deixar registrado o que o regime em janela NÃO precisa: o giro discreto.
*/
export function transporteDaOrbita(orbita: Orbita, raiz: Object3D): Transporte {
let saltos: number = 0;
let acumuladoM: number = 0;
return {
nome: 'alvo da órbita',
saltarPara(destinoLocal: Vector3): boolean {
raiz.localToWorld(destinoMundo.copy(destinoLocal));
const anterior: Vector3 = orbita.estado().alvo.clone();
orbita.olharPara(
new Vector3(destinoMundo.x, ALTURA_DO_ALVO_M, destinoMundo.z),
orbita.estado().raioM,
);
acumuladoM += anterior.distanceTo(orbita.estado().alvo);
saltos += 1;
return true;
},
deslizar(deltaMundo: Vector3): boolean {
const alvo: Vector3 = orbita.estado().alvo.clone().add(deltaMundo);
orbita.olharPara(alvo, orbita.estado().raioM);
acumuladoM += deltaMundo.length();
return true;
},
girar(): boolean {
// O giro discreto existe para evitar a rotação que quem observa não pediu.
// Na janela, toda rotação é pedida: ela vem do arrasto do cursor, no ritmo
// da mão de quem arrasta, e o descompasso entre o que os olhos veem e o
// que o ouvido interno registra não se instala — porque a imagem não
// preenche o campo de visão nem acompanha a cabeça. Discretizar aqui
// pioraria uma navegação que já é confortável.
return false;
},
reiniciar(): void {
saltos = 0;
acumuladoM = 0;
},
linhas(): string[] {
return [
'Regime em janela: quem se move é o alvo da órbita, e a câmera o acompanha. Serve para ' +
'conferir o salto no desktop, sem visor.',
`${saltos} saltos, somando ${acumuladoM.toFixed(2)} m de alvo deslocado.`,
'O giro discreto NÃO se aplica aqui, e a recusa é declarada em vez de silenciosa: na ' +
'janela toda rotação é pedida pelo arrasto, no ritmo da mão de quem arrasta.',
];
},
};
}O sinal é o mesmo que já se errou uma vez neste projeto. O deslocamento diz onde fica a origem do espaço novo dentro do atual, e as poses lidas no novo saem com ele invertido. Para a pessoa aparecer adiante, a origem recua.
O giro discreto tem uma armadilha própria, e ela é geométrica. Girar em torno da origem faria quem observa descrever um arco: a poucos metros dela, um passo de 30 graus deslocaria a pessoa mais de um metro de lado, sem que nada tivesse sido pedido. O pivô é a cabeça, e a conta que traduz isso leva o pivô à origem, gira e traz de volta.
Trinta graus por passo é o encontro de duas pressões opostas. Passos menores devolvem a sensação de rotação contínua, e o desconforto volta junto; passos maiores desorientam, e o custo aparece como dificuldade de encontrar de volta a bancada.
src/bancada/locomotion/giro.ts
// ---------------------------------------------------------------------------
// Virar e andar: as duas formas contínuas, e o que se faz com cada uma.
//
// Este arquivo contém as duas metades da comparação que o módulo pede, e elas
// convivem aqui de propósito. O giro DISCRETO é o que fica ligado; o deslize
// CONTÍNUO existe para ser experimentado e medido — é o caso ruim, e sem ele a
// comparação seria uma opinião entre duas coisas, das quais só uma teria sido
// vivida.
//
// O giro discreto é a mesma ideia do salto aplicada à rotação. Girar aos poucos
// produz, na periferia, exatamente o sinal de movimento próprio que o corpo não
// consegue conciliar com o repouso do ouvido interno; girar de uma vez não
// produz sinal nenhum, porque não há trajeto a ver. O passo é de trinta graus, e
// o número tem duas justificativas de sinais opostos que se encontram aí no
// meio. Passos menores fazem a rotação voltar a parecer contínua, e o desconforto
// volta junto. Passos maiores desorientam: a pessoa perde a conta de para onde
// está virada, e o custo aparece como dificuldade de encontrar de volta a
// bancada.
//
// O deslize contínuo é escrito com a velocidade de uma caminhada devagar e sem
// aceleração nenhuma. A aceleração é a parte que mais incomoda — velocidade
// constante o corpo tolera muito melhor do que a variação dela —, e deixá-la de
// fora torna a demonstração mais honesta: mesmo na versão mais gentil possível,
// o deslize ainda custa o que a comparação registra.
// ---------------------------------------------------------------------------
import { Object3D, Vector3 } from 'three';
import { SOLEIRA_DE_REARME, ZONA_MORTA, type LeituraDoManche, type Manche } from './manche';
import type { Transporte } from './transporte';
/**
* Quanto o giro discreto vira por passo, em graus.
*
* Trinta graus é o encontro de duas pressões opostas: menos que isso devolve a
* sensação de rotação contínua, e mais que isso desorienta quem está de visor.
*/
export const PASSO_DE_GIRO_GRAUS: number = 30;
/** Velocidade do deslize contínuo, em metros por segundo. Caminhada devagar. */
export const VELOCIDADE_DO_DESLIZE_M_S: number = 1.2;
/** Quem se desloca agora, e como. */
export type FormaDeMovimento = 'parado' | 'giro-discreto' | 'deslize-continuo';
export interface Movimento {
/**
* Avança um quadro.
*
* A cabeça entra porque o deslize é sempre na direção para onde se OLHA, e não
* na direção do controle. Andar para onde a mão aponta separa o corpo do
* caminho e produz uma trajetória que ninguém consegue prever de dentro.
*/
atualizar(cabeca: Object3D, deltaS: number): FormaDeMovimento;
/** Liga e desliga o deslize contínuo. Devolve o estado em que ficou. */
alternarDeslize(): boolean;
deslizeLigado(): boolean;
/** Verdadeiro enquanto há deslocamento contínuo em curso — é o que fecha a vinheta. */
deslizando(): boolean;
/** Metros percorridos em deslize contínuo desde o início. Entra no registro do ensaio. */
metrosPercorridos(): number;
linhas(): string[];
}
const adiante: Vector3 = new Vector3();
const lateral: Vector3 = new Vector3();
const passo: Vector3 = new Vector3();
const eixoVertical: Vector3 = new Vector3(0, 1, 0);
export function montarMovimento(manche: Manche, transporte: Transporte): Movimento {
let armado: boolean = true;
let deslizeLigado: boolean = false;
let emDeslize: boolean = false;
let passosDeGiro: number = 0;
let recusasDeGiro: number = 0;
let metrosDeslizados: number = 0;
let segundosDeslizando: number = 0;
return {
atualizar(cabeca: Object3D, deltaS: number): FormaDeMovimento {
const comando: LeituraDoManche = manche.ler();
emDeslize = false;
// A direção de onde se olha, projetada no plano do piso. Ela entra antes
// dos ramos porque tanto andar para a frente quanto andar de lado partem
// dela — e um passo lateral calculado sobre um "adiante" do quadro passado
// manda a pessoa para uma direção que nada na cena explica.
cabeca.getWorldDirection(adiante);
adiante.y = 0;
if (adiante.lengthSq() > 1e-6) {
adiante.normalize();
}
// As duas locomoções NÃO convivem, e é isso que faz delas alternativas em
// vez de camadas. Com o deslize ligado, o mesmo empurrão que abriria a
// mira do salto passa a andar; com ele desligado, o eixo horizontal volta
// a ser o giro discreto. Quem escolhe qual das duas atualizar no quadro é
// a composição, e a escolha é a resposta da tarefa de comparação.
if (deslizeLigado) {
passo.set(0, 0, 0);
if (Math.abs(comando.y) > ZONA_MORTA) {
// A intensidade da inclinação NÃO modula a velocidade, e essa é uma
// decisão de conforto: velocidade variável é aceleração, e aceleração
// é a parte que mais incomoda.
passo.addScaledVector(
adiante,
VELOCIDADE_DO_DESLIZE_M_S * deltaS * Math.sign(comando.y),
);
}
if (Math.abs(comando.x) > ZONA_MORTA) {
// O passo lateral existe porque é onde o deslocamento fica mais
// desconfortável: o campo periférico varre a cena inteira enquanto a
// cabeça continua apontada para a frente.
lateral.copy(adiante).cross(eixoVertical).normalize();
passo.addScaledVector(
lateral,
VELOCIDADE_DO_DESLIZE_M_S * deltaS * Math.sign(comando.x),
);
}
if (passo.lengthSq() > 0 && transporte.deslizar(passo)) {
metrosDeslizados += passo.length();
segundosDeslizando += deltaS;
emDeslize = true;
}
}
if (emDeslize) {
return 'deslize-continuo';
}
// O giro discreto usa o eixo horizontal e só dispara com o comando armado.
// Sem o rearme, manter o polegar inclinado giraria a cada quadro — noventa
// passos por segundo, meia volta antes de alguém perceber o que aconteceu.
if (!deslizeLigado && Math.abs(comando.x) > ZONA_MORTA && armado) {
armado = false;
const graus: number = comando.x > 0 ? -PASSO_DE_GIRO_GRAUS : PASSO_DE_GIRO_GRAUS;
if (transporte.girar(graus)) {
passosDeGiro += 1;
return 'giro-discreto';
}
recusasDeGiro += 1;
return 'parado';
}
if (Math.abs(comando.x) < SOLEIRA_DE_REARME) {
armado = true;
}
return 'parado';
},
alternarDeslize(): boolean {
deslizeLigado = !deslizeLigado;
return deslizeLigado;
},
deslizeLigado(): boolean {
return deslizeLigado;
},
deslizando(): boolean {
return emDeslize;
},
metrosPercorridos(): number {
return metrosDeslizados;
},
linhas(): string[] {
return [
`Giro discreto de ${PASSO_DE_GIRO_GRAUS}° por passo: ${passosDeGiro} passos dados, ` +
`${recusasDeGiro} recusados pelo transporte deste regime.`,
deslizeLigado
? `Deslize contínuo LIGADO, a ${VELOCIDADE_DO_DESLIZE_M_S.toFixed(1)} m/s sem ` +
'aceleração. Ele está aqui para ser sentido e medido, não para ficar ligado.'
: 'Deslize contínuo desligado. Ligue-o apenas para a comparação, e avise quem for ' +
'experimentá-lo.',
`Percorridos em deslize: ${metrosDeslizados.toFixed(2)} m em ` +
`${segundosDeslizando.toFixed(1)} s de movimento imposto ao corpo de quem observa.`,
'A velocidade não varia com a inclinação do comando. Velocidade variável é aceleração, e ' +
'aceleração é a parte que mais incomoda.',
];
},
};
}O regime em janela recebe um transporte próprio, e ele recusa o giro discreto em vez de simular um. Escada rolante parada ainda é escada; ambiente que só existe com o visor no rosto não é nada sem ele — mas nem toda função degrada, e esta não degrada porque não precisa. Na janela toda rotação é pedida pelo arrasto, no ritmo da mão de quem arrasta, e o descompasso vestibular não se instala. Discretizar ali pioraria uma navegação que já é confortável, e a recusa fica escrita como decisão.
1.4 Tarefa 2: Tratar o limite da área física
A borda do espaço real existe, não aparece dentro do ambiente, e é a única coisa deste percurso que o ambiente não tem como deduzir.
Enunciado da tarefa
O espaço real em que alguém se move tem borda, e a borda não aparece dentro do ambiente. Chegar perto dela sem aviso é como andar de olhos fechados numa sala alheia.
A tarefa fecha com a redução do campo periférico durante o deslocamento e com o comportamento definido para a aproximação do limite da área. Um ambiente que ignora a borda transfere para quem o usa um problema que só o sistema tem como enxergar.
1.4.1 A periferia, que é onde o desconforto mora
O mal-estar do deslocamento não vem do centro do campo de visão. Vem da periferia, que é a parte da visão especializada em detectar movimento próprio — e é ela que informa ao corpo, com muita convicção, que ele está sendo levado.
Reduzir o campo periférico enquanto o deslocamento dura é, portanto, intervenção precisa. Apaga-se o sinal mais forte e preserva-se o que a pessoa está olhando. O preço é visível: vê-se menos do ambiente enquanto se anda, e é por isso que a máscara abre de novo assim que o movimento para.
src/bancada/locomotion/vinheta.ts
// ---------------------------------------------------------------------------
// A vinheta: tirar da vista justamente o que mais informa que se está andando.
//
// O desconforto do deslocamento não vem do centro do campo de visão. Vem da
// periferia, que é a parte da visão especializada em detectar movimento próprio
// — e é ela que informa ao corpo, com muita convicção, que ele está sendo
// levado. No centro, a mesma cena passando produz um efeito bem menor.
//
// Reduzir o campo periférico enquanto o deslocamento dura é, portanto, uma
// intervenção precisa: apaga-se o sinal mais forte e preserva-se o que a pessoa
// está olhando. O preço é honesto e visível — vê-se menos do ambiente enquanto
// se anda —, e é por isso que a máscara abre de novo assim que o movimento para.
//
// Uma distinção que quase todo material perde: vinheta serve à locomoção
// CONTÍNUA. No salto não há trajeto a percorrer, e o que a máscara faz ali é
// outra coisa, com outro nome — uma piscada curta que cobre a troca de cena.
// Chamar as duas de vinheta faz alguém acreditar que a máscara é o remédio do
// teleporte, e o teleporte não precisa de remédio; o que ele precisa é de não
// trocar a imagem inteira sem aviso.
//
// O limite conhecido desta implementação está escrito aqui e não escondido. A
// máscara é geometria posta diante da câmera, e no visor cada olho tem a sua
// projeção: uma superfície a poucos centímetros do rosto produz discrepância
// entre os dois olhos. Ela é tolerável porque acontece na periferia, que é onde
// a acuidade é baixa; a implementação sem esse resíduo desenharia a máscara no
// estágio final da imagem, e isso é assunto de outra disciplina.
// ---------------------------------------------------------------------------
import {
BackSide,
Mesh,
MeshBasicMaterial,
Object3D,
Quaternion,
RingGeometry,
Vector3,
} from 'three';
/** A que distância do olho a máscara fica, em metros. */
const DISTANCIA_M: number = 0.35;
/** Raio interno com a máscara aberta, em metros. Nada é obstruído. */
const RAIO_ABERTO_M: number = 0.9;
/** Raio interno com a máscara fechada. Sobra o centro do campo de visão. */
const RAIO_FECHADO_M: number = 0.16;
/** Raio externo. Grande o bastante para cobrir a periferia inteira. */
const RAIO_EXTERNO_M: number = 3;
/** Em quanto tempo a máscara fecha ou abre por inteiro, em segundos. */
const TEMPO_DE_TRANSICAO_S: number = 0.25;
/** Quanto tempo a piscada do salto dura, em segundos. */
const PISCADA_S: number = 0.12;
export interface Vinheta {
/**
* Ajusta a máscara ao quadro.
*
* `fechando` é a intenção do momento: verdadeiro enquanto houver deslocamento
* contínuo em curso. A transição não é instantânea de propósito — máscara que
* aparece de uma vez é, ela própria, um estímulo brusco no campo periférico.
*/
atualizar(camera: Object3D, deltaS: number, fechando: boolean): void;
/** Fecha e reabre a máscara uma vez, para cobrir a troca de cena do salto. */
piscar(): void;
/** Quanto do campo periférico está obstruído agora, de zero a um. */
fechamento(): number;
linhas(): string[];
encerrar(): void;
}
// Temporários de leitura. A máscara se reposiciona a cada quadro, e alocar
// vetores aqui poria trabalho no coletor de lixo dentro do laço que menos pode
// engasgar — que é justamente o laço em que o engasgo vira desconforto físico.
const posicaoDaCabeca: Vector3 = new Vector3();
const orientacaoDaCabeca: Quaternion = new Quaternion();
const orientacaoDaRaiz: Quaternion = new Quaternion();
const adiante: Vector3 = new Vector3();
export function montarVinheta(raiz: Object3D): Vinheta {
const geometria: RingGeometry = new RingGeometry(RAIO_ABERTO_M, RAIO_EXTERNO_M, 48);
const material: MeshBasicMaterial = new MeshBasicMaterial({
color: 0x000000,
// Sem teste de profundidade: a máscara precisa cobrir o que estiver diante
// dela, inclusive a peça na mão, que fica mais perto que os trinta e cinco
// centímetros da máscara em boa parte do trabalho na bancada.
depthTest: false,
side: BackSide,
transparent: true,
opacity: 0.92,
});
const mascara: Mesh = new Mesh(geometria, material);
mascara.name = 'vinheta-de-conforto';
// Desenhada por último, depois de tudo o que ela deve cobrir.
mascara.renderOrder = 999;
mascara.visible = false;
mascara.frustumCulled = false;
raiz.add(mascara);
let fechamentoAtual: number = 0;
let piscadaRestanteS: number = 0;
let picoDeFechamento: number = 0;
let tempoFechadaS: number = 0;
function aplicarRaio(): void {
const raio: number = RAIO_ABERTO_M + (RAIO_FECHADO_M - RAIO_ABERTO_M) * fechamentoAtual;
// A geometria do anel não tem raio ajustável depois de criada, e recriá-la a
// cada quadro alocaria buffers no laço que menos pode engasgar. A escala
// resolve: o anel inteiro encolhe e cresce, e o que muda na vista é
// exatamente a abertura no meio.
const fator: number = raio / RAIO_ABERTO_M;
mascara.scale.set(fator, fator, 1);
mascara.visible = fechamentoAtual > 0.01;
}
return {
atualizar(camera: Object3D, deltaS: number, fechando: boolean): void {
// A máscara acompanha a cabeça copiando a pose dela, e não sendo filha da
// câmera. Dentro da sessão quem escreve a câmera é a biblioteca, a partir
// do que o aparelho reporta, e a árvore de nós não passa por ali: um filho
// da câmera ficaria parado onde nasceu.
camera.getWorldPosition(posicaoDaCabeca);
camera.getWorldQuaternion(orientacaoDaCabeca);
raiz.getWorldQuaternion(orientacaoDaRaiz);
// A pose vem em espaço de mundo e a máscara é filha da raiz, então a
// orientação precisa descontar a da raiz. Hoje a raiz não gira e as duas
// coincidem; escrever o desconto custa uma linha e evita que a máscara
// deite no dia em que a cena inteira for girada por outro motivo.
mascara.quaternion.copy(orientacaoDaRaiz.invert().multiply(orientacaoDaCabeca));
adiante.set(0, 0, -DISTANCIA_M).applyQuaternion(orientacaoDaCabeca);
mascara.position.copy(posicaoDaCabeca).add(adiante);
raiz.worldToLocal(mascara.position);
const alvo: number = fechando || piscadaRestanteS > 0 ? 1 : 0;
const passo: number = deltaS / TEMPO_DE_TRANSICAO_S;
if (fechamentoAtual < alvo) {
fechamentoAtual = Math.min(alvo, fechamentoAtual + passo);
} else {
fechamentoAtual = Math.max(alvo, fechamentoAtual - passo);
}
if (piscadaRestanteS > 0) {
piscadaRestanteS -= deltaS;
}
if (fechamentoAtual > picoDeFechamento) {
picoDeFechamento = fechamentoAtual;
}
if (fechamentoAtual > 0.01) {
tempoFechadaS += deltaS;
}
aplicarRaio();
},
piscar(): void {
piscadaRestanteS = PISCADA_S;
},
fechamento(): number {
return fechamentoAtual;
},
linhas(): string[] {
return [
`Campo periférico obstruído agora: ${(fechamentoAtual * 100).toFixed(0)}%. Pico da ` +
`sessão: ${(picoDeFechamento * 100).toFixed(0)}%. Tempo total com a máscara em cena: ` +
`${tempoFechadaS.toFixed(1)} s.`,
`A máscara fecha e abre em ${TEMPO_DE_TRANSICAO_S.toFixed(2)} s: aparecer de uma vez ` +
'seria, ela própria, um estímulo brusco na periferia.',
'Ela serve ao deslocamento contínuo. No salto o que existe é uma piscada de ' +
`${(PISCADA_S * 1000).toFixed(0)} ms, que cobre a troca de cena — e é outra coisa, ` +
'com outro nome.',
'Limite conhecido: a máscara é geometria diante da câmera, e no visor cada olho a projeta ' +
'de um jeito. A discrepância cai na periferia, onde a acuidade é baixa.',
];
},
encerrar(): void {
raiz.remove(mascara);
geometria.dispose();
material.dispose();
},
};
}Uma distinção que quase todo material perde: vinheta serve à locomoção contínua. No salto não há trajeto a percorrer, e o que a máscara faz ali é outra coisa — uma piscada curta que cobre a troca de cena. Chamar as duas pelo mesmo nome faz alguém acreditar que a máscara é o remédio do teleporte, e o teleporte não precisa de remédio.
A piscada vem antes do deslocamento, e não depois. Ela existe para esconder a troca; trocar primeiro para escurecer em seguida entregaria à vista exatamente o quadro que ela deveria cobrir.
O limite desta implementação está escrito no arquivo em vez de escondido. A máscara é geometria posta diante da câmera, e no visor cada olho a projeta de um jeito. A discrepância é tolerável porque cai na periferia, onde a acuidade é baixa; a versão sem esse resíduo desenharia a máscara no estágio final da imagem, e isso é assunto de outra disciplina.
1.4.2 A sala real, que o ambiente não adivinha
Onde a sala acaba? Escala, alcance e conforto se resolviam com o corpo de quem entrou. A borda do espaço livre é propriedade do cômodo, e nenhuma conta dentro do ambiente chega até ela.
A plataforma tem a resposta, e ela vem por um espaço de referência próprio: o espaço limitado, que além da origem no piso traz o polígono da área que o aparelho considera segura. Quem o desenhou foi quem instalou o visor, caminhando pelo cômodo.
src/bancada/locomotion/limite.ts
// ---------------------------------------------------------------------------
// A borda da sala real, que não aparece dentro do ambiente.
//
// Este é o único módulo do percurso em que o ambiente precisa saber algo sobre o
// cômodo em que a pessoa está. Tudo o mais até aqui — escala, alcance, conforto —
// se resolvia com o corpo de quem entrou; a borda do espaço livre é uma
// propriedade da sala, e o ambiente não tem como adivinhá-la.
//
// A plataforma tem a resposta, e ela vem por um espaço de referência próprio: o
// espaço limitado, que além da origem no piso traz o POLÍGONO da área em que o
// aparelho considera seguro andar. Quem o desenhou foi quem instalou o visor,
// caminhando pelo cômodo.
//
// A decisão de projeto que este arquivo toma é a de pedir esse espaço APENAS
// para ler a fronteira, sem trocar o espaço de trabalho por ele. Trocar mudaria
// a origem do mundo no meio da sessão — a bancada saltaria para o centro da área
// livre —, e o preço seria alto demais por uma informação que não precisa ser a
// origem de nada. O que se quer é a lista de vértices.
//
// A ausência também é resposta, e é a mais comum: no celular não há área
// desenhada, e em alguns visores ela não é concedida. Aí o ambiente declara que
// não sabe onde a sala acaba, em vez de inventar um retângulo plausível. Um
// retângulo inventado é pior que nenhum: ele acende a cerca no lugar errado, e
// quem confia nela caminha para a parede.
// ---------------------------------------------------------------------------
import {
BufferGeometry,
Line,
LineBasicMaterial,
Object3D,
Vector3,
type WebGLRenderer,
} from 'three';
/**
* A que distância da borda a cerca começa a aparecer, em metros.
*
* Meio metro é aproximadamente um passo. Avisar mais cedo faria a cerca viver
* acesa numa sala pequena, e uma cerca sempre acesa deixa de ser aviso; avisar
* mais tarde chega junto com a parede.
*/
export const MARGEM_DE_AVISO_M: number = 0.5;
/** O que se sabe sobre a área física desta sessão. */
export interface AreaFisica {
/** Falso quando o aparelho não declarou área alguma. */
readonly conhecida: boolean;
/** Vértices do polígono, no plano do piso e em coordenadas do espaço de referência. */
readonly vertices: readonly Vector3[];
readonly porQue: string;
}
/** Como está a relação entre quem observa e a borda, neste quadro. */
export interface EstadoDaBorda {
readonly conhecida: boolean;
/** Distância da cabeça até a borda mais próxima, em metros. */
readonly distanciaM: number;
readonly dentro: boolean;
readonly avisando: boolean;
}
export interface LimiteDaArea {
/** Consulta a área à sessão recém-aberta. Assíncrona porque o pedido é assíncrono. */
consultar(): Promise<AreaFisica>;
/** Afere a posição da cabeça contra a borda e acende ou apaga a cerca. */
atualizar(cabeca: Object3D): EstadoDaBorda;
/** Esquece a área ao fim da sessão: a próxima pode ser em outro cômodo. */
esquecer(): void;
linhas(): string[];
}
const posicaoDaCabeca: Vector3 = new Vector3();
/**
* Distância de um ponto ao contorno do polígono, e de que lado ele está.
*
* A conta é a do ponto ao segmento, repetida para cada aresta, com o menor valor
* vencendo. O teste de dentro e fora é o do lançamento de raio: conta-se quantas
* arestas um raio horizontal cruza, e um número ímpar significa que o ponto está
* dentro. É o mesmo teste de sempre, e ele vale para polígono de qualquer forma
* — o que importa, porque área desenhada à mão raramente é retângulo.
*/
function medirContraOPoligono(
ponto: Vector3,
vertices: readonly Vector3[],
): { distanciaM: number; dentro: boolean } {
let menor: number = Number.POSITIVE_INFINITY;
let dentro: boolean = false;
for (let i: number = 0; i < vertices.length; i += 1) {
const a: Vector3 | undefined = vertices[i];
const b: Vector3 | undefined = vertices[(i + 1) % vertices.length];
if (a === undefined || b === undefined) {
continue;
}
const dx: number = b.x - a.x;
const dz: number = b.z - a.z;
const comprimento: number = dx * dx + dz * dz;
const t: number =
comprimento === 0
? 0
: Math.max(
0,
Math.min(1, ((ponto.x - a.x) * dx + (ponto.z - a.z) * dz) / comprimento),
);
const distancia: number = Math.hypot(ponto.x - (a.x + t * dx), ponto.z - (a.z + t * dz));
if (distancia < menor) {
menor = distancia;
}
if (a.z > ponto.z !== b.z > ponto.z) {
const cruzamento: number = a.x + ((ponto.z - a.z) / (b.z - a.z)) * dx;
if (ponto.x < cruzamento) {
dentro = !dentro;
}
}
}
return { distanciaM: menor === Number.POSITIVE_INFINITY ? 0 : menor, dentro };
}
export function montarLimiteDaArea(renderer: WebGLRenderer, raiz: Object3D): LimiteDaArea {
const material: LineBasicMaterial = new LineBasicMaterial({ color: 0xffb020 });
const geometria: BufferGeometry = new BufferGeometry().setFromPoints([
new Vector3(),
new Vector3(),
]);
const cerca: Line = new Line(geometria, material);
cerca.name = 'cerca-da-area';
cerca.visible = false;
cerca.frustumCulled = false;
raiz.add(cerca);
let area: AreaFisica = {
conhecida: false,
vertices: [],
porQue: 'Fora de sessão não há área a consultar.',
};
let ultimo: EstadoDaBorda = {
conhecida: false,
distanciaM: 0,
dentro: true,
avisando: false,
};
let avisos: number = 0;
let saidas: number = 0;
function desenharCerca(): void {
if (!area.conhecida || area.vertices.length < 3) {
cerca.visible = false;
return;
}
// A cerca é desenhada de pé, com um metro de altura em cada vértice, e não
// no chão: a linha rente ao piso some do campo de visão de quem está olhando
// para a frente, que é justamente a postura de quem vai andar para a parede.
const pontos: Vector3[] = [];
for (let i: number = 0; i <= area.vertices.length; i += 1) {
const vertice: Vector3 | undefined = area.vertices[i % area.vertices.length];
if (vertice === undefined) {
continue;
}
pontos.push(new Vector3(vertice.x, 0.02, vertice.z));
pontos.push(new Vector3(vertice.x, 1.0, vertice.z));
pontos.push(new Vector3(vertice.x, 0.02, vertice.z));
}
geometria.setFromPoints(pontos);
}
return {
async consultar(): Promise<AreaFisica> {
const sessao: XRSession | null = renderer.xr.getSession();
if (sessao === null) {
area = {
conhecida: false,
vertices: [],
porQue: 'Não há sessão aberta, e área física é fato da sessão.',
};
return area;
}
try {
const espaco: XRReferenceSpace = await sessao.requestReferenceSpace('bounded-floor');
const limitado: XRBoundedReferenceSpace = espaco as XRBoundedReferenceSpace;
const contorno: readonly DOMPointReadOnly[] = limitado.boundsGeometry;
if (contorno.length < 3) {
area = {
conhecida: false,
vertices: [],
porQue:
'O aparelho concedeu o espaço limitado e devolveu um contorno com menos de três ' +
'vértices, que não delimita área alguma.',
};
return area;
}
area = {
conhecida: true,
vertices: contorno.map((vertice) => new Vector3(vertice.x, 0, vertice.z)),
porQue:
`Área declarada pelo aparelho, com ${contorno.length} vértices. Quem a desenhou foi ` +
'quem instalou o visor, caminhando pelo cômodo.',
};
desenharCerca();
return area;
} catch {
// A recusa é a resposta, e não a falha — a mesma leitura que o ciclo de
// sessão faz dos espaços de referência. Aqui ela significa: este
// aparelho não sabe onde a sala acaba.
area = {
conhecida: false,
vertices: [],
porQue:
'O aparelho recusou o espaço limitado. Não há área declarada, e o ambiente NÃO ' +
'inventa um retângulo: cerca no lugar errado é pior que cerca nenhuma.',
};
return area;
}
},
atualizar(cabeca: Object3D): EstadoDaBorda {
if (!area.conhecida) {
cerca.visible = false;
ultimo = { conhecida: false, distanciaM: 0, dentro: true, avisando: false };
return ultimo;
}
cabeca.getWorldPosition(posicaoDaCabeca);
const medida: { distanciaM: number; dentro: boolean } = medirContraOPoligono(
posicaoDaCabeca,
area.vertices,
);
const avisando: boolean = !medida.dentro || medida.distanciaM < MARGEM_DE_AVISO_M;
if (avisando && !ultimo.avisando) {
avisos += 1;
}
if (!medida.dentro && ultimo.dentro) {
saidas += 1;
}
cerca.visible = avisando;
ultimo = {
conhecida: true,
distanciaM: medida.distanciaM,
dentro: medida.dentro,
avisando,
};
return ultimo;
},
esquecer(): void {
area = {
conhecida: false,
vertices: [],
porQue: 'Sessão encerrada. A próxima pode ser em outro cômodo, com outra área.',
};
cerca.visible = false;
},
linhas(): string[] {
const linhas: string[] = [area.porQue];
if (!area.conhecida) {
linhas.push(
'Sem área declarada, o comportamento definido é este: nenhuma cerca, e o registro de ' +
'que o ambiente não sabe. Quem conduz a sessão assume a vigilância do espaço, e isso ' +
'precisa estar dito em vez de suposto.',
);
return linhas;
}
linhas.push(
ultimo.dentro
? `Dentro da área, a ${ultimo.distanciaM.toFixed(2)} m da borda mais próxima.`
: `FORA da área, a ${ultimo.distanciaM.toFixed(2)} m da borda. A cerca está acesa.`,
);
linhas.push(
`Cerca acesa em ${MARGEM_DE_AVISO_M.toFixed(2)} m da borda — cerca de um passo. ` +
`${avisos} aproximações e ${saidas} saídas registradas nesta sessão.`,
);
linhas.push(
'A cerca é desenhada de pé, e não rente ao piso: a linha no chão sai do campo de visão de ' +
'quem olha para a frente, que é a postura de quem está prestes a andar para a parede.',
);
linhas.push(
'O salto NÃO é limitado por esta borda. Ela restringe o que o corpo pode andar, e é ' +
'justamente para ir além dela que o salto existe.',
);
return linhas;
},
};
}A decisão de projeto aqui é pedir esse espaço apenas para ler a fronteira, sem trocar o espaço de trabalho por ele. Trocar mudaria a origem do mundo no meio da sessão, e a bancada saltaria para o centro da área livre diante de quem estivesse montando. O preço seria alto demais por uma informação que não precisa ser a origem de nada.
A ausência também é resposta, e é a mais comum: no celular não há área desenhada, e alguns visores não a concedem. Aí o ambiente declara que não sabe onde a sala acaba, e a vigilância do espaço passa explicitamente a quem conduz a sessão. Um retângulo inventado seria pior que nenhum — ele acende a cerca no lugar errado, e quem confia nela caminha para a parede.
A cerca é desenhada de pé, com um metro de altura em cada vértice. Linha rente ao piso sai do campo de visão de quem olha para a frente, que é justamente a postura de quem está prestes a andar para a parede.
E o salto não é limitado por essa borda. Ela restringe o que o corpo pode andar, e é para ir além dela que o salto existe. Confundir os dois transformaria a solução no problema que ela veio resolver.
1.5 Tarefa 3: Comparar as duas locomoções por critério
Preferência sem critério é a resposta que não sobrevive à primeira pergunta.
Enunciado da tarefa
Movimento contínuo e movimento discreto trocam continuidade da experiência por conforto, e a escolha entre eles se sustenta por critério declarado.
O que fica pronto é o registro da comparação: o que cada forma custa, em que situação cada uma foi testada e por que a escolhida foi escolhida. Preferência sem critério é a resposta que não sobrevive à primeira pergunta.
1.5.1 As duas metades da comparação
Uma comparação entre duas formas exige que as duas existam. Por isso o deslize contínuo está implementado, e não descrito: ele é o caso ruim, e sem ele a comparação seria uma opinião entre duas coisas das quais só uma foi vivida.
A implementação do deslize é deliberadamente gentil. Velocidade de caminhada devagar, sem aceleração nenhuma, e a inclinação do manche não modula a velocidade — porque velocidade variável é aceleração, e a aceleração é a parte que mais incomoda. Mesmo na versão mais benigna possível, o custo aparece.
src/bancada/locomotion/comparacao.ts
// ---------------------------------------------------------------------------
// O registro da comparação: preferência não sobrevive à primeira pergunta.
//
// A terceira tarefa do módulo não pede uma implementação — pede um documento que
// se sustente. E a razão de ele viver aqui, como dado tipado, é a mesma que já
// fez os limites do regime em janela e os campos da tomada de apontar virarem
// estrutura em vez de parágrafo: o que precisa ser confrontado depois não
// sobrevive escrito em prosa dentro de um comentário.
//
// A comparação tem duas metades, e confundi-las é o defeito que este arquivo
// existe para impedir.
//
// A primeira é o que o AMBIENTE mede sozinho: quantos metros foram percorridos
// de cada jeito, quanto tempo o campo periférico ficou obstruído, quantos
// engasgos houve durante o movimento. São números, e eles não dependem de
// ninguém opinar.
//
// A segunda é o que só uma PESSOA responde: como foi. E aqui vale a regra que o
// módulo inteiro sustenta — quem construiu a locomoção já se habituou ao próprio
// movimento e deixou de sentir o que ele produz. O relato que vale é o de quem
// experimentou sem ter construído, e o campo do observador existe para que a
// origem do dado fique gravada junto com ele.
//
// O que este arquivo NÃO faz: inventar o resultado do ensaio. Os ensaios nascem
// vazios, e um registro sem ensaio algum diz isso em vez de exibir uma conclusão
// que ninguém verificou.
// ---------------------------------------------------------------------------
/** As duas formas de deslocamento que o ambiente implementa. */
export type FormaDeLocomocao = 'salto' | 'deslize';
/** Quem responde a este critério: o ambiente, medindo, ou uma pessoa, relatando. */
export type QuemResponde = 'instrumentação' | 'relato de quem experimentou';
export interface CriterioDeComparacao {
readonly criterio: string;
readonly noSalto: string;
readonly noDeslize: string;
readonly quemResponde: QuemResponde;
readonly comoSeMede: string;
}
/**
* Os critérios, escritos antes de qualquer ensaio.
*
* A ordem importa: os dois primeiros são o que cada forma custa, e são eles que
* decidem. Os quatro seguintes não decidem — eles impedem que a decisão seja
* defendida com meia verdade, que é o que acontece quando só o conforto é
* mencionado e a perda de continuidade fica de fora.
*/
export const CRITERIOS: readonly CriterioDeComparacao[] = [
{
criterio: 'Conflito entre o que os olhos veem e o que o ouvido interno registra',
noSalto:
'não se instala, porque não há trajeto: entre o antes e o depois não existe imagem de ' +
'movimento a conciliar com o repouso do corpo',
noDeslize:
'instala-se desde o primeiro segundo e cresce com o tempo de exposição, mesmo na versão ' +
'sem aceleração alguma',
quemResponde: 'relato de quem experimentou',
comoSeMede:
'sessões curtas, em quem foi avisado, com o relato colhido logo depois — e nunca de quem ' +
'construiu a locomoção, que já se habituou a ela',
},
{
criterio: 'Continuidade da experiência',
noSalto:
'perde-se: o ambiente troca de cena de uma vez, e a relação entre onde se estava e onde ' +
'se está precisa ser reconstruída a cada salto',
noDeslize: 'preserva-se inteira, e é o único critério em que ele ganha com folga',
quemResponde: 'relato de quem experimentou',
comoSeMede:
'pedir a quem acabou de se deslocar que aponte, sem olhar, para onde a bancada ficou',
},
{
criterio: 'Precisão do destino',
noSalto: 'alta: o destino é mirado e visto antes de ser confirmado, e é cancelável',
noDeslize: 'depende de quanto tempo se segura o comando, e o excesso se corrige indo e voltando',
quemResponde: 'instrumentação',
comoSeMede: 'distância entre o ponto pretendido e o ponto alcançado, repetida algumas vezes',
},
{
criterio: 'Dependência do espaço físico livre',
noSalto: 'nenhuma: alcança o que está além da área em que o corpo pode andar',
noDeslize:
'nenhuma para o ambiente, e é justamente esse o risco — o corpo fica parado enquanto o ' +
'mundo anda, e a borda real deixa de dar qualquer pista',
quemResponde: 'instrumentação',
comoSeMede: 'contar as aproximações da borda registradas durante o deslocamento de cada forma',
},
{
criterio: 'Custo por quadro',
noSalto:
'concentrado no gesto: a parábola se recalcula enquanto a mira está aberta, e nada é ' +
'gasto no resto do tempo',
noDeslize:
'contínuo e baixo, mas somado ao da vinheta, que fica em cena durante todo o deslocamento',
quemResponde: 'instrumentação',
comoSeMede: 'o envelope do quadro e o contador de engasgos, comparados com e sem deslocamento',
},
{
criterio: 'Quanto se aprende antes de usar',
noSalto: 'um gesto novo, que ninguém traz de fora e que precisa ser mostrado uma vez',
noDeslize: 'nenhum: é o gesto que qualquer pessoa que já jogou alguma coisa traz pronto',
quemResponde: 'relato de quem experimentou',
comoSeMede: 'observar a primeira tentativa de quem nunca entrou neste ambiente, sem instruir',
},
];
/** Um ensaio realmente feito, com o dado que só uma pessoa fornece. */
export interface Ensaio {
readonly forma: FormaDeLocomocao;
/** Quem experimentou. Nunca quem construiu — o hábito apaga o efeito. */
readonly observador: string;
readonly duracaoS: number;
/** Como a pessoa relatou ter se sentido, em texto e nas palavras dela. */
readonly relato: string;
/** O que o ambiente mediu durante o mesmo ensaio. */
readonly metrosPercorridos: number;
readonly engasgosNoPeriodo: number;
}
/** A decisão, e o que a sustenta. */
export interface Decisao {
readonly escolhida: FormaDeLocomocao;
readonly porQue: string;
readonly oQueSePerdeu: string;
readonly quandoSeriaOutra: string;
}
/**
* A escolha do projeto, escrita antes dos ensaios e sujeita a eles.
*
* Declarar a decisão de antemão parece inverter a ordem, e não inverte: ela é a
* hipótese do projeto, e os ensaios existem para confirmá-la ou derrubá-la. O
* que seria inversão é escrever a justificativa depois, moldada ao que se
* decidiu por outros motivos.
*/
export const DECISAO: Decisao = {
escolhida: 'salto',
porQue:
'o único critério em que o deslize ganha — continuidade da experiência — não compensa o ' +
'único em que ele perde, porque perder ali significa uma sessão que a pessoa não consegue ' +
'terminar. Uma oficina de montagem se usa por dezenas de minutos, e não por dois.',
oQueSePerdeu:
'a relação espacial entre um ponto e o seguinte, que passa a ser reconstruída a cada salto. ' +
'A piscada curta reduz o susto da troca, e não devolve a continuidade perdida.',
quandoSeriaOutra:
'em um ambiente percorrido por poucos segundos de cada vez, ou em um que já se assista ' +
'sentado e com o corpo apoiado, o deslize volta a ser defensável — e a comparação teria de ' +
'ser refeita, não herdada daqui.',
};
export interface RegistroDaComparacao {
/** Acrescenta um ensaio feito de verdade. */
registrar(ensaio: Ensaio): void;
ensaios(): readonly Ensaio[];
linhas(): string[];
}
export function montarRegistroDaComparacao(): RegistroDaComparacao {
const feitos: Ensaio[] = [];
return {
registrar(ensaio: Ensaio): void {
feitos.push(ensaio);
},
ensaios(): readonly Ensaio[] {
return feitos;
},
linhas(): string[] {
const linhas: string[] = [];
for (const criterio of CRITERIOS) {
linhas.push(`${criterio.criterio} [${criterio.quemResponde}]`);
linhas.push(` no salto — ${criterio.noSalto}`);
linhas.push(` no deslize — ${criterio.noDeslize}`);
linhas.push(` como se mede — ${criterio.comoSeMede}`);
}
linhas.push('');
linhas.push(`Forma adotada: ${DECISAO.escolhida}.`);
linhas.push(` por quê — ${DECISAO.porQue}`);
linhas.push(` o que se perdeu — ${DECISAO.oQueSePerdeu}`);
linhas.push(` quando seria outra — ${DECISAO.quandoSeriaOutra}`);
linhas.push('');
if (feitos.length === 0) {
linhas.push(
'Nenhum ensaio registrado ainda. A decisão acima é a hipótese do projeto, e enquanto ' +
'ninguém tiver experimentado as duas formas ela continua sendo exatamente isso.',
);
return linhas;
}
for (const ensaio of feitos) {
linhas.push(
`${ensaio.forma} — ${ensaio.duracaoS.toFixed(0)} s, ` +
`${ensaio.metrosPercorridos.toFixed(1)} m, ${ensaio.engasgosNoPeriodo} engasgos. ` +
`Relato de ${ensaio.observador}: "${ensaio.relato}"`,
);
}
return linhas;
},
};
}O registro separa o que o ambiente mede do que só uma pessoa responde, e confundir as duas metades é o defeito que o arquivo existe para impedir. Metros percorridos, tempo com o campo obstruído e engasgos no período são números, e não dependem de ninguém opinar. Como foi, só quem experimentou responde.
E aqui vale a regra que sustenta o módulo inteiro: quem construiu a locomoção já se habituou ao próprio movimento e deixou de sentir o que ele produz. O relato que vale é o de quem experimentou sem ter construído, e o campo do observador existe para que a origem do dado fique gravada junto com ele.
Os critérios estão escritos antes de qualquer ensaio, e a decisão do projeto também. Isso parece inverter a ordem, e não inverte: a decisão declarada é a hipótese, e os ensaios existem para confirmá-la ou derrubá-la. O que seria inversão é escrever a justificativa depois, moldada ao que já se decidiu por outros motivos.
Os ensaios nascem vazios, e o relatório diz isso em letras claras enquanto ninguém tiver experimentado as duas formas. Exibir uma conclusão que ninguém verificou seria o oposto do que a tarefa pede.
1.5.2 O que a decisão custou
A forma adotada é o salto, e o argumento cabe em duas frases. O único critério em que o deslize ganha — continuidade da experiência — não compensa o único em que ele perde, porque perder ali significa uma sessão que a pessoa não consegue terminar. Uma oficina de montagem se usa por dezenas de minutos, e não por dois.
O que se perdeu está escrito ao lado, e não escondido. A relação espacial entre um ponto e o seguinte passa a ser reconstruída a cada salto. A piscada curta reduz o susto da troca de cena; ela não devolve a continuidade perdida, e dizer que devolve seria a mentira mais confortável deste módulo.
O registro guarda também quando a decisão seria outra. Num ambiente percorrido por poucos segundos de cada vez, ou num que já se assista sentado e com o corpo apoiado, o deslize volta a ser defensável. A comparação teria de ser refeita naquele contexto, e não herdada daqui.
1.6 A composição, e o que ela acrescentou
Sete arquivos novos, e a lista dos que não mudaram é o recibo.
A camada de locomoção entra inteira numa pasta própria. A composição instancia os sete arquivos, decide qual transporte vale no regime em uso, chama três coisas no laço e liga o par de avisos do ciclo de sessão. É tudo.
O roteamento entre os dois transportes mora na composição pela mesma razão que a troca de fonte de apontamento mora lá: quem sabe em que regime o ambiente está é essa camada, e é a única que pode saber sem que o conhecimento vaze para baixo.
A ordem no laço tem consequência. A locomoção entra depois da manipulação, e é isso que faz a peça na mão acompanhar quem salta: ela pende do nó portador, e o portador acompanha a mão. Invertendo as duas linhas, a peça ficaria um quadro para trás a cada salto — defeito que aparece como tremor e que ninguém atribui à ordem das chamadas.
As setas do teclado fazem, na janela, o papel do manche. O teclado continua conhecido só na composição, que é a única camada com licença para conhecer um aparelho pelo nome; a locomoção recebe um par de números e não tem como perguntar de onde ele veio. É a mesma licença que o giro da peça já usava.
Nenhum arquivo de seleção, de agarre ou de montagem foi tocado neste módulo. A promessa feita quatro módulos atrás continua sendo paga.
1.7 O que este módulo deixa pronto
O percurso imersivo passa a ter o que o percurso em janela não tem, e é isso que faz dos dois caminhos distintos.
O ambiente ganhou o salto com arco de mira, o giro em passos de 30 graus, a máscara de conforto e a leitura da borda física da sala. As quatro coisas existem no regime imersivo e não têm contraparte na janela — e é justamente essa assimetria que faz dos dois percursos caminhos diferentes, e não a mesma cena com um rótulo trocado.
O deslize contínuo fica no pacote, desligado, à espera de ser experimentado. Ele é a metade da comparação que a terceira tarefa exige, e ligá-lo por engano é o tipo de coisa que se descobre em poucos segundos.
O que fica pendente é o registro dos ensaios, e ele depende de gente. Os critérios estão escritos, a instrumentação mede sozinha, e o relatório declara com todas as letras que a decisão continua sendo hipótese enquanto ninguém tiver experimentado as duas formas. O próximo módulo pousa a bancada sobre uma superfície real, e ali a borda física deixa de ser aviso e passa a ser referência.