Referenciais e Hierarquias
Um modelo hierárquico com estados.
Este exemplo ilustra uma animação com várias caraterísticas:
- O modelo é uma hierarquia de objetos e sub-objetos, suportada por transformações entre os espaços (referenciais) adequados.
- O modelo tem estados que controlam as «fases» da animação.
- Implementa um «mini sistema gráfico» declarativo (como o
SVGou oX3D) sobre oC2D.
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:
trdefine uma transformação que se aplica a esse objeto e a todos os seus descendentes (children).shapedefine a geometria do objeto.styledefine as propriedades visuais do objeto.childrendefine 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 detruck, que é descendente demodel. - Há uma aresta pontilhada, de
modelparabin, que representa uma referência. Isto é,binnão é sub-elemento demodelmas é 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:
- A forma (
shape) é definida por um caminho que usa a sintaxe dos caminhosSVG(o atributoddos elementospath) - As propriedades visuais (
style) definem como é pintado e traçado. - 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
C2Dcom 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
C2Dpara visualizar o objeto gráfico.
Se o objeto gráfico tiver definidas uma forma e propriedades visuais, é necessário:
- Definir a forma no sistema
C2D. - 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» |
|---|
de forma que
| Canto | Coordenadas |
|---|---|
| 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
updatefaz 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:
- Vira-se para a «direita» e desloca-se «em frente».
- Levanta o contentor.
- Baixa o contentor.
- 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ção | Fase | Duração (ciclos) | Fase seguinte |
|---|---|---|---|
| camião para a direita | 0 | 150 | 1 |
| levantar contentor | 1 | 50 | 2 |
| baixar contentor | 2 | 50 | 3 |
| camião para a esquerda | 3 | 150 | 0 |
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
HTMLcom 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
- As durações e sequências das fases da animação estão diretamente definidas na função
updatedo 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
updatedo modelo. - Acrescente a definição das fases na inicialização do modelo.
- 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».