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.