Skip to content

Lição 3 - Grafo de cena e transformação ​

Na última lição, desenhamos um círculo. Nesta lição, você vai aprender o seguinte:

  • Transformações. Fazer as formas suportarem transformações de deslocamento, zoom, rotação e cisalhamento.
  • Grafo de cena.

Por fim, vamos usar os recursos acima para realizar um modelo simples do Sistema Solar.

js
(async () => {
    const { Canvas, Circle, Group } = Lesson3;
    const canvas = await Utils.createCanvas(Canvas, 400, 400);

    const solarSystem = new Group();
    const earthOrbit = new Group();
    const moonOrbit = new Group();

    const sun = new Circle({
        cx: 0,
        cy: 0,
        r: 100,
        fill: 'red',
    });
    const earth = new Circle({
        cx: 0,
        cy: 0,
        r: 50,
        fill: 'blue',
    });
    const moon = new Circle({
        cx: 0,
        cy: 0,
        r: 25,
        fill: 'yellow',
    });
    solarSystem.appendChild(sun);
    solarSystem.appendChild(earthOrbit);
    earthOrbit.appendChild(earth);
    earthOrbit.appendChild(moonOrbit);
    moonOrbit.appendChild(moon);

    solarSystem.position.x = 200;
    solarSystem.position.y = 200;
    earthOrbit.position.x = 100;
    moonOrbit.position.x = 100;

    canvas.appendChild(solarSystem);

    let id;
    const animate = () => {
        solarSystem.rotation += 0.01;
        earthOrbit.rotation += 0.02;
        canvas.render();
        id = requestAnimationFrame(animate);
    };
    animate();

    unsubscribe(() => {
        cancelAnimationFrame(id);
        canvas.destroy();
    });
    return canvas.getDOM();
})();

Transformação ​

Transform CSS oferece estas transformações: translate scale rotate e skew. As matrizes por trás dessas transformações podem ser encontradas em Transformações - LearnOpenGL. Como a nossa cena contém apenas gráficos 2D, precisamos apenas de uma matriz 3x3 e, como a última linha de [0, 0, 1] é fixa, na verdade só precisamos armazenar 6 elementos da matriz:

bash
| a | c | tx|
| b | d | ty|
| 0 | 0 | 1 |

Adicionamos um atributo transform à nossa classe base gráfica usando diretamente o @pixi/math ou, é claro, o gl-matrix.

ts
import { Transform } from '@pixi/math';

export abstract class Shape {
    transform = new Transform();
}

Antes de adicionar mais métodos, vamos apresentar um conceito importante.

Coordenadas locais e de mundo ​

Sistemas de coordenadas podem ser usados para descrever a posição, a rotação e a escala de objetos em uma cena; o sistema de coordenadas mais conhecido é o sistema de coordenadas euclidiano. Em gráficos, também usamos o sistema de coordenadas do centro de gravidade. O espaço euclidiano pode conter até N dimensões; aqui vamos usar apenas duas dimensões.

Quando dizemos "a lua gira em torno da terra", na verdade estamos ignorando objetos que não sejam a terra. No sistema de coordenadas local da lua, ela simplesmente gira em torno de um ponto, embora, no sistema de coordenadas de mundo de todo o sistema solar, a terra ainda gire em torno do sol, e a lua acabe seguindo a trajetória complexa descrita acima.

Os conceitos de sistemas de coordenadas local e de mundo podem ser usados tanto em mundos 2D quanto 3D. A imagem abaixo, de playcanvas, mostra o sistema de coordenadas de mundo à esquerda, e você pode ver que os eixos são sempre os mesmos. O lado direito mostra o sistema de coordenadas local do cubo, cujos eixos mudam conforme o objeto é transformado (neste caso, rotacionado); então, se nesse momento esse objeto rotacionado sofrer uma translação na direção positiva do eixo X (em vermelho), ele pode sair do chão.

espaço de mundo (esquerda) e espaço local (direita)

O sistema de coordenadas de mundo é compartilhado por todos os nós do grafo de cena, então ele tem uma origem fixa (0, 0), e a orientação dos eixos XYZ (eixos XY na cena 2D) é fixa. Mesmo que a caixa na cena gire sobre si mesma, o sistema de coordenadas de mundo não muda para ela. Mas, para o seu próprio sistema de coordenadas local, a origem não é mais (0, 0), e sim a posição do próprio objeto, e os eixos naturalmente mudam. Como o nome sugere, ele está associado ao próprio objeto.

Imagine que a caixa seja movida "10 unidades ao longo do eixo X (vermelho)", o que tem um significado completamente diferente em um sistema de coordenadas diferente. Portanto, quando queremos transformar um objeto, primeiro temos que especificar o sistema de coordenadas em que ele está.

Além disso, o sistema de coordenadas local também é chamado de sistema de coordenadas do modelo, o que é mais conveniente ao descrever a transformação do próprio modelo. Se queremos fazer cada soldado virar a cabeça, é obviamente mais fácil fazer isso no sistema de coordenadas local, porque a transformação "virar" é relativa à cabeça de cada modelo. espaço local e espaço de mundo

Adicionamos métodos para transformações nos sistemas de coordenadas local e de mundo à classe base gráfica, fornecidos em @pixi/math:

ts
export abstract class Shape {
    get localTransform(): Matrix {
        return this.transform.localTransform;
    }
    get worldTransform(): Matrix {
        return this.transform.worldTransform;
    }
}

A figura a seguir, de Fundamentos de computação gráfica, 4ª edição, mostra o sistema de coordenadas local (do objeto) sendo transformado por uma transformação de modelo para o sistema de coordenadas de mundo, depois por uma transformação de câmera para o sistema de coordenadas da câmera, depois por uma transformação de projeção para o sistema de coordenadas de recorte (espaço de recorte/NDC) e, por fim, por uma transformação de viewport para o sistema de coordenadas da tela (espaço da tela/de pixels). Vamos apresentar a câmera mais adiante; por enquanto, só precisamos nos importar com a transformação de modelo.

coordenadas

Em seguida, precisamos passar a matriz de transformação de modelo ao shader para transformar as posições dos vértices.

Alinhamento ​

No Vertex Shader, a matriz de transformação de modelo é passada via Uniform e então multiplicada à esquerda do vetor de posição:

glsl
layout(std140) uniform ShapeUniforms {
  mat3 u_ModelMatrix;
};

vec2 position = (u_ModelMatrix * vec3(a_Position + a_Size * a_FragCoord, 1)).xy;

Naturalmente, criamos diretamente um Float32Array de comprimento 9 (uma matriz de 3 * 3):

ts
this.#uniformBuffer = device.createBuffer({
    viewOrSize: Float32Array.BYTES_PER_ELEMENT * 9, // mat3
    usage: BufferUsage.UNIFORM,
    hint: BufferFrequencyHint.DYNAMIC,
});

Mas então o console relata um erro dizendo que o Uniform Buffer que criamos não é longo o bastante. O que está acontecendo?

bash
[.WebGL-0x10800c78f00] GL_INVALID_OPERATION: It is undefined behaviour to use a uniform buffer that is too small.

Aqui precisamos introduzir o conceito de Layout de memória. O Uniform Block suporta as regras de layout packed shared std140 std430. Regras de layout diferentes levam a maneiras diferentes de armazenar e ler dados no buffer. A vantagem de escolher std140 é que não há diferenças de layout entre Programs (em comparação com packed) e não há diferenças entre implementações de OpenGL (em comparação com shared), mas a desvantagem é que você precisa lidar com o alinhamento manualmente, e a documentação no site oficial dá um aviso sobre isso, lembrando que devemos tentar evitar vec3 para contornar o alinhamento:

Aviso: às vezes as implementações erram o layout std140 para componentes vec3. Recomenda-se preencher manualmente suas estruturas/arrays e evitar completamente o uso de vec3.

Então, o que é alinhamento? Vamos tomar vec3 como exemplo: ele tem 4 * 3 bytes de comprimento, mas na verdade ocupa 16. Na prática, as regras de alinhamento são muito complexas, e aqui mat3 na verdade ocupa 4 * 12 bytes de armazenamento:

ts
this.#uniformBuffer = device.createBuffer({
    viewOrSize: Float32Array.BYTES_PER_ELEMENT * 12, // mat3
    usage: BufferUsage.UNIFORM,
});

wgsl-offset-computer é uma ótima ferramenta online para ajudar a entender as regras de alinhamento por meio de visualização:

struct: ShapeUniforms

Precisamos adicionar padding manualmente ao gravar os dados:

bash
| a | c | tx|
| b | d | ty|
| 0 | 0 | 1 |
| padding | padding | padding |
ts
const PADDING = 0;
const { a, b, c, d, tx, ty } = this.worldTransform;
this.#uniformBuffer.setSubData(
    0,
    new Uint8Array(
        new Float32Array([
            a,
            b,
            0,
            PADDING,
            c,
            d,
            0,
            PADDING,
            tx,
            ty,
            1,
            PADDING,
        ]).buffer,
    ),
);

Vale observar que o mesmo Alinhamento de buffers Uniform e Storage está disponível em WGSL, e há o bytemuck no ecossistema Rust para ajudar a automatizar o alinhamento. O exemplo a seguir vem do bevy:

rust
use bytemuck::{Pod, Zeroable};

#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct UiMaterialVertex {
    pub position: [f32; 3],
    pub uv: [f32; 2],
    pub border_widths: [f32; 4],
}

Vamos adicionar APIs de transformação, como translação, rotação e zoom, aos gráficos.

Translação ​

Translação 2D no WebGL

ts
export abstract class Shape {
    get position(): ObservablePoint {
        return this.transform.position;
    }
    set position(value: IPointData) {
        this.transform.position.copyFrom(value);
    }

    get x(): number {
        return this.position.x;
    }
    set x(value: number) {
        this.transform.position.x = value;
    }

    get y(): number {
        return this.position.y;
    }
    set y(value: number) {
        this.transform.position.y = value;
    }
}

O uso é consistente com o PIXI.js:

js
circle = call(() => {
    const { Circle } = Lesson3;
    return new Circle({
        cx: 100,
        cy: 100,
        r: 50,
        fill: 'red',
    });
});
js
positionX = Inputs.range([0, 100], { label: 'position.x', value: 0, step: 1 });
js
positionY = Inputs.range([0, 100], { label: 'position.y', value: 0, step: 1 });
js
call(() => {
    circle.position.x = positionX;
    circle.position.y = positionY;
});
js
(async () => {
    const { Canvas } = Lesson3;
    const canvas = await Utils.createCanvas(Canvas, 200, 200);
    canvas.appendChild(circle);

    let id;
    const animate = () => {
        canvas.render();
        id = requestAnimationFrame(animate);
    };
    animate();

    unsubscribe(() => {
        cancelAnimationFrame(id);
        canvas.destroy();
    });
    return canvas.getDOM();
})();

Pivô ​

Rotação, escala e cisalhamento exigem que o centro da transformação seja especificado. No Pixi.js, nós o chamamos de pivot. Ele não é o mesmo que transform-origin no CSS. Veja: Posicionamento no PixiJS.

a rotação é em torno da origem/pivô, que por padrão fica no canto superior esquerdo

pivot também afeta o deslocamento da posição do objeto.

o pivô também afeta o deslocamento da posição do objeto
ts
export abstract class Shape {
    get pivot(): ObservablePoint {
        return this.transform.pivot;
    }
    set pivot(value: IPointData) {
        this.transform.pivot.copyFrom(value);
    }
}

Rotação ​

ts
export abstract class Shape {
    get rotation(): number {
        return this.transform.rotation;
    }
    set rotation(value: number) {
        this.transform.rotation = value;
    }
}
js
circle2 = call(() => {
    const { Circle } = Lesson3;
    const circle = new Circle({
        cx: 0,
        cy: 0,
        r: 50,
        fill: 'red',
    });
    circle.position = { x: 100, y: 100 };
    return circle;
});
js
pivotX = Inputs.range([0, 100], { label: 'pivot.x', value: 0, step: 1 });
js
pivotY = Inputs.range([0, 100], { label: 'pivot.y', value: 0, step: 1 });
js
call(() => {
    circle2.pivot.x = pivotX;
    circle2.pivot.y = pivotY;
});
js
(async () => {
    const { Canvas } = Lesson3;
    const canvas = await Utils.createCanvas(Canvas, 200, 200);
    canvas.appendChild(circle2);

    let id;
    const animate = () => {
        circle2.rotation += 0.01;
        canvas.render();
        id = requestAnimationFrame(animate);
    };
    animate();

    unsubscribe(() => {
        cancelAnimationFrame(id);
        canvas.destroy();
    });
    return canvas.getDOM();
})();

Escala ​

ts
export abstract class Shape {
    get scale(): ObservablePoint {
        return this.transform.scale;
    }
    set scale(value: IPointData) {
        this.transform.scale.copyFrom(value);
    }
}
js
circle3 = call(() => {
    const { Circle } = Lesson3;
    return new Circle({
        cx: 0,
        cy: 0,
        r: 50,
        fill: 'red',
    });
});
js
pivotX2 = Inputs.range([0, 100], { label: 'pivot.x', value: 0, step: 1 });
js
pivotY2 = Inputs.range([0, 100], { label: 'pivot.y', value: 0, step: 1 });
js
scaleX = Inputs.range([0, 5], { label: 'scale.x', value: 1, step: 0.1 });
js
scaleY = Inputs.range([0, 5], { label: 'scale.y', value: 1, step: 0.1 });
js
call(() => {
    circle3.pivot.x = pivotX2;
    circle3.pivot.y = pivotY2;
    circle3.scale.x = scaleX;
    circle3.scale.y = scaleY;
    circle3.position.x = 100;
    circle3.position.y = 100;
});
js
(async () => {
    const { Canvas } = Lesson3;
    const canvas = await Utils.createCanvas(Canvas, 200, 200);
    canvas.appendChild(circle3);

    let id;
    const animate = () => {
        canvas.render();
        id = requestAnimationFrame(animate);
    };
    animate();

    unsubscribe(() => {
        cancelAnimationFrame(id);
        canvas.destroy();
    });
    return canvas.getDOM();
})();

Cisalhamento ​

ts
export abstract class Shape {
    get skew(): ObservablePoint {
        return this.transform.skew;
    }
    set skew(value: IPointData) {
        this.transform.skew.copyFrom(value);
    }
}

Grafo de cena ​

Um Grafo de cena é uma estrutura de dados para organizar e gerenciar cenas virtuais 2D/3D e é um grafo acíclico dirigido. O grafo de cena oferece duas grandes capacidades:

  1. descrever relações pai-filho
  2. automatizar alguns cálculos complexos em cascata com base nas relações pai-filho.

O painel esquerdo no Figma mostra o grafo de cena:

grafo de cena no Figma

Imagine que precisamos construir um cenário simples de sistema solar com as seguintes relações hierárquicas:

bash
solarSystem
   |    |
   |   sun
   |
 earthOrbit
   |    |
   |  earth
   |
 moonOrbit
      |
     moon

Use esta descrição de API, em que Group simplesmente herda a classe base gráfica e não precisa sobrescrever os métodos de renderização:

ts
const solarSystem = new Group();
const earthOrbit = new Group();
const moonOrbit = new Group();

const sun = new Circle({
    cx: 0,
    cy: 0,
    r: 100,
    fill: 'red',
});
const earth = new Circle({
    cx: 0,
    cy: 0,
    r: 50,
    fill: 'blue',
});
const moon = new Circle({
    cx: 0,
    cy: 0,
    r: 25,
    fill: 'yellow',
});
solarSystem.appendChild(sun);
solarSystem.appendChild(earthOrbit);
earthOrbit.appendChild(earth);
earthOrbit.appendChild(moonOrbit);
moonOrbit.appendChild(moon);

Relações pai-filho ​

Adicione as propriedades parent e children aos gráficos:

ts
export abstract class Shape {
    parent: Shape;
    readonly children: Shape[] = [];
}

Em seguida, adicionamos o método de nó append/removeChild. Se o nó filho já tiver tido um nó pai adicionado antes, ele é removido primeiro. A definição de _parentID aqui está relacionada à implementação do @pixi/math, tratada na última subseção:

ts
export abstract class Shape {
  appendChild(child: Shape) {
    if (child.parent) {
      child.parent.removeChild(child);
    }
    child.parent = this;
    child.transform._parentID = -1;
    this.children.push(child);

    return child;
  }
}

Percorrer o grafo de cena ​

Com a hierarquia definida, podemos percorrer todo o grafo de cena usando recursão, adicionando um método utilitário aqui:

ts
export function traverse(shape: Shape, callback: (shape: Shape) => void) {
    callback(shape);
    shape.children.forEach((child) => {
        traverse(child, callback);
    });
}

Durante a renderização da tela, use os métodos utilitários acima para renderizar cada gráfico por vez:

ts
export class Canvas {
  render() {
    const { hooks } = this.#pluginContext;
    hooks.beginFrame.call();
    this.#shapes.forEach((shape) => {
      traverse(shape, (s) => {
        hooks.render.call(s);
      });
    });
    hooks.endFrame.call();
  }
}

Atualizar a transformação ​

No grafo de cena, a matriz de transformação de um nó filho no sistema de coordenadas de mundo é calculada da seguinte forma:

bash
child's WorldTransform = parent's WorldTransform
    * child's LocalTransform

Concluímos a atualização da matriz de transformação antes de renderizar cada quadro:

ts
hooks.render.tap((shape) => {
  shape.transform.updateTransform(
    shape.parent ? shape.parent.transform : IDENTITY_TRANSFORM,
  );
  shape.render(this.#device, this.#renderPass, this.#uniformBuffer);
});

Você pode se preocupar: se o gráfico não for transformado, ele ainda precisa ser atualizado a cada quadro? Afinal, o grafo de cena inteiro precisa executar operações em cada gráfico, o que é muita sobrecarga. Apresentamos esse padrão de projeto em Lição 2 - Flag suja, mas agora vamos analisar a implementação do @pixi/math.

Cada vez que uma transformação ocorre, a transformação de translação, por exemplo, apenas incrementa o número de versão _localID:

ts
this.position = new ObservablePoint(this.onChange, this, 0, 0);

protected onChange(): void {
    this._localID++;
}

Ao atualizar a matriz de transformação local, o número de versão é usado para determinar se a transformação não ocorreu desde a última vez; caso contrário, as operações reais de matriz são executadas. Quando termina, atualize o número de versão e force a atualização da matriz de transformação de mundo, o que é feito redefinindo _parentID:

ts
updateTransform(parentTransform: Transform): void {
    const lt = this.localTransform;
    if (this._localID !== this._currentLocalID) {
        // Perform actual matrix operations
        lt.a = this._cx * this.scale.x;
        // ...
        this._currentLocalID = this._localID;
        this._parentID = -1;
    }
    if (this._parentID !== parentTransform._worldID) {
        this._parentID = parentTransform._worldID;
        this._worldID++;
    }
}

Lembra do novo método appendChild que adicionamos antes? Como o pai mudou, precisamos redefinir _parentID para que a matriz no sistema de coordenadas de mundo seja recalculada na próxima atualização:

ts
appendChild(child: Shape) {
    child.transform._parentID = -1;
}

Esse padrão também é usado no PIXI.js para operações de alta sobrecarga, como calcular a caixa envolvente.

Leitura adicional ​

Released under the MIT License.