Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Referenciais e Hierarquias

Um modelo hierárquico com estados.

TERMINAL

Este exemplo ilustra uma animação com várias caraterísticas:

  1. O modelo é uma hierarquia de objetos e sub-objetos, suportada por transformações entre os espaços (referenciais) adequados.
  2. O modelo tem estados que controlam as «fases» da animação.
  3. Implementa um «mini sistema gráfico» declarativo (como o SVG ou o X3D) sobre o C2D.

Hierarquias de Objetos gráficos

A cena deve organizar os objetos gráficos num grafo que representa a estrutura do modelo.

Neste exemplo temos o seguinte grafo — os nós retangulares representam formas e os nós ovais representam «grupos».

---
config:
  theme: neutral
---
graph TD;
model([model]) ==> truck([truck])
model -.-> bin

truck ==> bin([bin])
truck ==> chassis
truck ==> cabin
truck ==> glass
truck ==> wheel1
truck ==> wheel2
truck ==> wheel3

bin ==> box

model ===> floor
model ===> background

Cada grupo define um «espaço» que organiza os seus descendentes. Para tal, no âmbito deste exemplo, definimos um objeto gráfico como um objeto javascript com os seguintes atributos:

{
  tr: {x: ..., y: ..., alpha: ..., sx: ..., sy: ...},
  shape: ...,
  style: { fill: ..., stroke: ..., lineWidth: ... },
  children: [ ... ]
}

em que:

  • tr define uma transformação que se aplica a esse objeto e a todos os seus descendentes (children).
  • shape define a geometria do objeto.
  • style define as propriedades visuais do objeto.
  • children define os descendentes (sub-objetos subordinados) que herdam a transformação.

Além disso:

  • Cada atributo e sub-atributo é opcional.
  • As arestas sólidas representam descendentes. Por exemplo, glass é descendente de truck, que é descendente de model.
  • Há uma aresta pontilhada, de model para bin, que representa uma referência. Isto é, bin não é sub-elemento de model mas é referido por um atributo: model.bin.

Esta convenção equilibra «capacidade expressiva» com «simplicidade».


Por exemplo, o objeto («grupo») bin fica definido por:

{
  tr: { x: -0.85, y: -0.7, alpha: 0 * Math.PI },
  children: [
    {
      tr: { x: 0.5, y: 0.5, sx: 0.5, sy: 0.5 },
      shape: "M 0 0 m 1 0.5 l -1.5 0 l -0.5 -0.5 l 0 -0.5 l 2 0 l 0 1 Z",
      style: {
        fill: "maroon",
        stroke: "firebrick",
        lineWidth: 0.2,
      },
    },
  ],
}

Este objeto não tem nem forma (shape) nem propriedades visuais (style). No entanto, define uma transformação (tr: { x: -0.85, y: -0.7, alpha: 0 * Math.PI }) e tem um descendente (box no diagrama acima). Neste descendente:

  1. A forma (shape) é definida por um caminho que usa a sintaxe dos caminhos SVG (o atributo d dos elementos path)
  2. As propriedades visuais (style) definem como é pintado e traçado.
  3. A transformação (tr) posiciona-o (x: ..., y: ...) e dimensiona-o (sx: ..., sy: ...) no referencial do seu parente.

Um Sistema Gráfico Dedicado

A visualização de cenas (e modelos) com aquele tipo de objetos gráficos assenta numa extensão do sistema C2D com métodos e funções adequadas.

Transformação

Definir o espaço/referencial do objeto gráfico no objeto «parente».

A transformação, se definida, aplica-se logo no início da visualização porque afeta as instruções de desenho e, também, os descendentes.

/**
A Graphical Object is an object with attributes:
- `tr`: a transformation: `x,y` translation; `alpha` rotation; `sx,sy` scale;
- `children`: sub-objects;
- `shape`: A "path" program;
- `style`: visual properties:  `stroke`; `fill`; `lineWidth`;
*/
function render(grobj) {
  if (grobj.tr) {
    const x = grobj.tr.x || 0.0;
    const y = grobj.tr.y || 0.0;
    const alpha = grobj.tr.alpha || 0.0;
    const sx = grobj.tr.sx || 1.0;
    const sy = grobj.tr.sy || 1.0;
    this.save();
    this.translate(x, y);
    this.rotate(alpha);
    this.scale(sx, sy);
  }

  // Further instructions
  
  if (grobj.tr) this.restore();
}

A última instrução

  if (grobj.tr) this.restore();

garante que os efeitos da (eventual) transformação aplicada no início são repostos no estado anterior.

Descendentes

Aplicar recursivamente a visualização aos descendentes.

A visualização dos descendentes (se definidos) usa exatamente a mesma função de visualização:

function render(grobj) {
  if (grobj.tr) { ... }

  if (grobj.children)
    grobj.children.forEach((child) => this.render(child));

  // Further instructions
  
  if (grobj.tr) this.restore();
}

Aqui está-se a utilizar o método Array.forEach porque o código fica mais legível.

Visualização

Usar o sistema C2D para visualizar o objeto gráfico.

Se o objeto gráfico tiver definidas uma forma e propriedades visuais, é necessário:

  1. Definir a forma no sistema C2D.
  2. Aplicar as propriedades visuais que estejam definidas; «traçar» o contorno; «pintar» o interior.
function render(grobj) {
  if (grobj.tr) { ... }

  if (grobj.children) { ... }

  if (grobj.shape && grobj.style) {
    const shape = new Path2D(grobj.shape);  // Geometry
    if (grobj.style.stroke) {               // Stroke?
      const prev_lineWidth = this.lineWidth;
      this.lineWidth = grobj.style.lineWidth || this.lineWidth;
      this.strokeStyle = grobj.style.stroke;
      this.stroke(shape);
      this.lineWidth = prev_lineWidth;
    }
    if (grobj.style.fill) {                 // Fill?
      this.fillStyle = grobj.style.fill;
      this.fill(shape);
    }
  }

  if (grobj.tr) this.restore();
}

As instruções

const prev_lineWidth = this.lineWidth;
...
this.lineWidth = prev_lineWidth;

são necessárias porque a propriedade C2D.lineWidth não fica registada com o método .save(). Desta forma (1) guardam o valor prévio desse atributo e depois (2) repõem o valor guardado.

Objetos Primitivos

Funções de suporte para algumas formas e transformações simples.

Um quadrado centrado na origem e de lado 2:

function urect() {
  return "M 0 0 m -1 -1 l 2 0 l 0 2 l -2 0 l 0 -2 Z";
}

Um círculo centrado na origem e de raio 1:

function ucirc() {
  return "M 0 0 m 1 0 a 1 1 0 1 0 -2 0 a 1 1 0 1 0 2 0";
}

A transformação identidade:

function tid() {
  return { x: 0.0, y: 0.0, sx: 1.0, sy: 1.0, alpha: 0.0 };
}

Inicialização

Criar um contexto gráfico adequado.

A inicialização do contexto adiciona o método render (explicado acima) a um CanvasRenderingContext2D. Adicionalmente, ajusta a dimensão do canvas respetivo e ajusta o referencial com:

Orientação «Matemática»
referencial cartesiano

de forma que

CantoCoordenadas
Superior Esquerdo$(-1, +1)$
Inferior Esquerdo$(-1, -1)$
Superior Direito$(+1, +1)$
Inferior Direito$(+1, -1)$

Adicionalmente, a inicialização do contexto gráfico também acrescenta o método print para mostrar mensagens de texto num elemento indicado.

function new_context(container_id, terminal_id, width, height) {
  const context = document.getElementById(container_id).getContext("2d");

  const terminal = document.getElementById(terminal_id);
  context.print = (text) => (terminal.innerHTML = text);

  context.render = render;

  context.canvas.width = width;
  context.canvas.height = height;
  context.save();

  context.lineWidth = 1.0 / Math.max(context.canvas.width, context.canvas.height, 1);

  context.translate(0.5 * context.canvas.width, 0.5 * context.canvas.height);
  context.scale(0.5 * context.canvas.width, -0.5 * context.canvas.height);

  return context;
}

A instrução

  context.lineWidth = 1.0 / Math.max(context.canvas.width, context.canvas.height, 1);

ajusta a espessura do traço a 1% da maior dimensão da tela.

As duas instruções

  context.translate(0.5 * context.canvas.width, 0.5 * context.canvas.height);
  context.scale(0.5 * context.canvas.width, -0.5 * context.canvas.height);

ativam a orientação «Matemática» do referencial e definem as coordenadas dos cantos como mostrado acima.

A Biblioteca Gráfica

Toda a funcionalidade do contexto gráfico fica agrupada numa biblioteca (minigrsys.js)

export { urect, ucirc, tid, new_context };

function urect() { ... }
function ucirc() { ... }
function tid() { ... }
function new_context(container_id, terminal_id, width, height) { ... }
function render(grobj) { ... }

Modelo

O modelo deve conter informação sobre o grafo de cena, a «fase» da animação, o estado de «pausa» e um método para a atualização.

  • O grafo de cena tem os elementos gráficos, cada um com transformações; forma; propriedades visuais; descendentes.
  • As «fases» da animação são: andar para a direita; «descarregar» a carga; voltar à «base».
  • Se a animação está em «pausa» os atributos não são atualizados.
  • O método update faz a atualização passo-a-passo do modelo.
  • É ainda necessário integrar informação «temporal»: o ciclo (age); e o carimbo temporal da atualização anterior (last_ts).

Representação da informação «temporal» e das fases da animação:

function init_model() {
  const model = {
    age: 0,
    paused: false,
    phase: 0,
    phase_age: 0,
  };
  
  // Further instructions
  
  model.last_ts = performance.now();
  return model;
}

O atributo age é o contador de ciclos de animação que o modelo «viveu» e paused controla se o modelo está, ou não em modo «pausa».

Mais à frente, na explicação do método update, vai ser mostrado como a «pausa» afeta a evolução do modelo mas por enquanto interessa associar-lhe um evento de teclado:

function init_model() {
  const model = { ... };

  document.addEventListener("keydown", (e) => {
    if (e.key === "p") {
      model.paused = !model.paused;
    }
  });
  
  // Further instructions
  
  model.last_ts = performance.now();
  return model;
}

Desta forma cada vez que é premida a tecla p o valor Booleano de model.paused é invertido: o modelo passa de paused para running e vice-versa.

Descendentes

Além de definir os descendentes de um modelo é também conveniente identificar alguns desses descendentes com «acesso direto».

function init_model() {
  const model = { ... };

  document.addEventListener("keydown", ... );
  
  model.add_child = add_child;
  
  // Further instructions
  
  model.last_ts = performance.now();
  return model;
}

function add_child(name, grobj) {
  if (!this.children) this.children = [];
  this.children.push(grobj);
  this[name] = grobj;
}

O método add_child pode ser adicionado a qualquer objeto gráfico; acrescenta um descendente (grobj) a esse objeto e identifica-o pelo atributo name.

  • Sem este método continuaria a ser possível aceder a cada descendente, pelo seu índice na lista/atributo .children — uma forma pouco ágil.

O fundo (background) e o chão (floor) são colocados no modelo desta forma:

function init_model() {
  const model = { ... };

  document.addEventListener("keydown", ... );
  
  model.add_child = add_child;
  
  model.add_child("background", {
    shape: urect(),
    tr: { x: 0.0, y: 0.0, sx: 1.0, sy: 1.0, },
    style: { fill: "lightskyblue" },
  });

  model.add_child("floor", {
    shape: urect(),
    tr: { x: 0.0, y: -0.9, sx: 1.0, sy: 0.1, },
    style: { fill: "slategray" },
  });
  
  model.last_ts = performance.now();
  return model;
}

Desta forma o modelo passou a ter mais dois atributos: background e floor que referem diretamente o fundo e o chão respetivamente.


Note-se que o fundo é um retângulo unitário centrado ({x: 0.0, y: 0.0}) e dimensionado ({sx: 1.0, sy: 1.0}) de forma a cobrir toda a área da tela — neste caso esta transformação não é necessária porque coincide com a posição e dimensão de urect.


A inicialização do modelo ainda não está completa: falta adicionar o camião e o método de atualização.

Camião

Este é o «ator» principal da animação: desloca-se para a esquerda e para a direita e tem vários descendentes. Desses, o contentor também tem a sua animação, rodando quando o camião para.

Dada a (relativa) complexidade do camião, assim como do contentor, estes objetos gráficos são definidos em funções dedicadas.

O contentor fica definido em:

function bin() {
  return {
    tr: { x: -0.85, y: -0.5, alpha: 0 * Math.PI },
    children: [
      { // The rotation axis
        tr: { sx: 0.05, sy: 0.05 }, 
        shape: ucirc(), 
        style: { fill: "black" } },
      { // The bin "sub-object"
        tr: { x: 0.5, y: 0.3, sx: 0.5, sy: 0.5 },
        shape: "M 0 0 m 1 0.5 l -1.5 0 l -0.5 -0.5 l 0 -0.5 l 2 0 l 0 1 Z",
        style: {
          fill: "maroon",
          stroke: "firebrick",
          lineWidth: 0.2,
        },
      },
    ],
  };
}

O aspeto mais interessante deste objeto gráfico tem a ver com a forma como é rodado: Não em torno do centro da geometria, mas do eixo assinalado pelo (semi) ponto preto.

Para se obter esta rotação «fora do centro» é preciso «re-centrar» o objeto gráfico para o eixo pretendido; e, além de ajustar o tamanho, é precisamente esse o papel da transformação «local»:

function bin() {
  return {
    ...
      { // The bin "sub-object"
        tr: { x: 0.5, y: 0.3, sx: 0.5, sy: 0.5 },
        ...
      },
    ...  
  };
}

O camião tem o contentor, e outro objetos, como descendentes:

function truck() {
  const truck_obj = {
    tr: tid(),
    children: [
      { // Chassis
        shape: urect(),
        tr: { x: -0.05, y: -0.6, sx: 0.8, sy: 0.05 },
        style: { fill: "darkolivegreen" },
      },
      { // Cabin
        shape: "M 0 0 l 1 0 l 0 -1 l -2 0 l 0 2 l 1 0 l 0 -1 Z",
        style: { fill: "darkgreen" },
        tr: { x: 0.5, y: -0.3, sx: 0.25, sy: 0.25 },
      },
      { // Glass
        shape: "M 0 0 l 1 0  a 1 1 0 0 1 -1 1 l 0 -1 Z",
        style: { fill: "lightcyan" },
        tr: { x: 0.5, y: -0.3, sx: 0.25, sy: 0.25 },
      },
      wheel(-0.65, -0.7), // 3 Wheels
      wheel(-0.35, -0.7), // each defined by the
      wheel(0.5, -0.7),   // same function...
    ],
  };
  // The bin
  truck_obj.add_child = add_child;
  truck_obj.add_child("bin", bin());

  return truck_obj;
}

As rodas fazem parte do camião. Como objeto gráfico, são simples, mas são três. Para não repetir código, definem-se numa função:

function wheel(x, y) {
  return {
    shape: ucirc(),
    tr: { x: x, y: y, sx: 0.05, sy: 0.05 },
    style: {
      fill: "gray",
      stroke: "black",
      lineWidth: 2.0,
    },
  };
}

A inicialização do modelo fica completa com o camião e com uma referência ao contentor:

function init_model() {
  const model = { ... };

  document.addEventListener("keydown", ... );
  
  model.add_child = add_child;
  
  model.add_child("background", ... );
  model.add_child("floor", ... );
  
  model.add_child("truck", truck());
  // Adjust the position
  model.truck.tr = { x: -0.8, y: -0.4, sx: 0.25, sy: 0.5 };
  // Get a reference to the bin.
  model["bin"] = model.truck.bin;

  model.update = update;
  
  model.last_ts = performance.now();
  return model;
}

e falta apenas definir a atualização — o método update.

Atualização

Esta animação tem fases distintas, onde acontece o movimento do camião e do contentor.

O movimento do camião segue uma sequência fixa de fases:

  1. Vira-se para a «direita» e desloca-se «em frente».
  2. Levanta o contentor.
  3. Baixa o contentor.
  4. Vira-se para a «esquerda» e desloca-se «em frente».

Além disso, cada fase dura exatamente um certo número de ciclos.

A atualização começa pelo tempo decorrido deste a atualização mais recente:

function update(ts) {
  const dt = 0.001 * (ts - this.last_ts);
  this.last_ts = ts;
  
  // Further instructions
}

Antes de se atualizar a contagem de ciclos (age) e outros atributos, é necessário tratar das pausas.

Pausas

Quando o modelo está em «pausa» os seus atributos não mudam.

Portanto, em modo «pausa», a atualização fica completa após ser processado this.last_ts:

function update(ts) {
  const dt = ts - this.last_ts;
  const last_ts = ts;

  if (this.paused) return;
  
  // Further instructions
}

A instrução

  if (this.paused) return;

impede que, quando this.paused é true, sejam atualizados os restantes atributos do modelo.

Caso contrário, se this.paused for false, o método update continua com a contagem de ciclos. Neste caso também importa contar os ciclos na fase atual:

function update(ts) {
  // Previous instructions
  
  this.age += 1;
  this.phase_age += 1;  
  
  // Further instructions
}

Fases

Em cada fase o modelo faz uma única animação.

Os atributos model.phase e model.phase_age controlam qual é a animação «ativa» e quantos ciclos decorreram nessa fase.

As fases controlam a animação «ativa» de acordo com a seguinte tabela:

AnimaçãoFaseDuração (ciclos)Fase seguinte
camião para a direita01501
levantar contentor1502
baixar contentor2503
camião para a esquerda31500
function update(ts) {
  // Previous instructions
  
  if (this.phase === 0 && this.phase_age >= 150) {
    this.phase = 1;
    this.phase_age = 0;
  } else if (this.phase === 1 && this.phase_age >= 50) {
    this.phase = 2;
    this.phase_age = 0;
  } else if (this.phase === 2 && this.phase_age >= 50) {
    this.phase = 3;
    this.phase_age = 0;
    this.truck.tr.sx *= -1;
  } else if (this.phase === 3 && this.phase_age >= 150) {
    this.phase = 0;
    this.phase_age = 0;
    this.truck.tr.sx *= -1;
  }

  // Further instructions
}

Estas instruções verificam se a fase «ativa» expirou; nesse caso é «ativada» a fase seguinte.

Após a determinação da fase «ativa» são aplicadas as respetivas transformações

function update(ts) {
  // Previous instructions

  if (this.phase === 0) this.truck.tr.x += 0.01;
  if (this.phase === 1) this.bin.tr.alpha += 0.01 * Math.PI;
  if (this.phase === 2) this.bin.tr.alpha -= 0.01 * Math.PI;
  if (this.phase === 3) this.truck.tr.x -= 0.01;
}

e o modelo fica atualizado.

A Biblioteca do Modelo

A biblioteca do modelo junta as funções auxiliares, a inicialização e a atualização do modelo no ficheiro truck_model.js.

import { urect, ucirc, tid } from "minigrsys";
export { init_model };

function wheel(x, y) { ... }
function bin() { ... }
function truck() { ... }
function add_child(name, grobj) { ... }
function init_model() { ... }
function update(ts) { ... }

Esta biblioteca importa algumas funções do sistema gráfico, para facilitar a definição das formas e transformações dos objetos gráficos definidos aqui.

A única função exportada é init_model, que devolve um objeto que descreve o grafo de cena e com os atributos e métodos adequados à animação.

O HTML e o Ciclo de Animação

Esta animação existe num documento HTML com certos elementos presentes. O controlo da animação é definido numa função própria (main).

O mapa de importação define as referências para as bibliotecas:

<script type="importmap">
    {
        "imports": {
            "minigrsys": "./minigrsys.js",
            "model": "./truck_model.js",
            "main": "./refs_main.js"
        }
    }
</script>

A visualização decorre num elemento <canvas> e as mensagens de texto num <div>. Estes dois elementos são alinhados numa coluna:

<div style="
    display:grid;
    grid-template-columns:512px;
    margin:0 auto;
    width: fit-content">
    <div id="refs:terminal" style="
        font-family: monospace;
        padding:0.5em;
        background:black;
        color:seagreen">TERMINAL</div>
    <canvas id="refs:canvas"></canvas>
</div>

A animação inicia-se com a função main que tem como argumentos os id relevantes e o tamanho da visualização:

<script type="module">
    import { main } from "main";
    main("refs:canvas", "refs:terminal",
      512, 256);
</script>

O controlo da animação está definido na biblioteca refs_main.js, que importa (as bibliotecas para) o sistema gráfico e o modelo e exporta a função main, com:

  • A inicialização do modelo e do contexto (sistema) gráfico.
  • A definição do passo da animação (step).
  • O início do ciclo de animação.
import { new_context } from "minigrsys";
import { init_model } from "model";

export { main };

function main(container_id, terminal_id, width, height) {
  
  const model = init_model();
  const context = new_context(container_id, terminal_id, width, height);
  
  function step(ts) {
    model.update(ts);
    context.render(model);
    context.print(
      `AGE: ${model.age} PHASE: ${model.phase} ${model.paused ? "PAUSED" : "RUNNING"}`,
    );
    requestAnimationFrame(step);
  }

  requestAnimationFrame(step);
}

Exercícios

  1. As durações e sequências das fases da animação estão diretamente definidas na função update do modelo — isso é má prática. Melhore o modelo de forma a incluir as fases, as respetivas durações e a fase seguinte. Isto é:
    • Descreva cada fase com um objeto, por exemplo:
    {
      id: int,        // The Id of this phase
      duration: int,  // The target duration (in cycles)
      age: int,       // If active, the current duration
      next: int,      // The next phase
      action: (object) => { ... } // A function to execute
                      // at each update, when this phase is active
      start: (object) => { ... } // A function to execute
                      // when this phase becomes active
    }
    
    • Integre a gestão de uma lista de fases no método update do modelo.
    • Acrescente a definição das fases na inicialização do modelo.
  2. Em vez das durações das animações serem controladas pelo número de ciclos, defina-as de forma a serem controladas, por exemplo, por uma posição «alvo» ou um ângulo «alvo».