Trabalho Prático 1

Prazo de entrega: 23h59 de 14/10/2016

Changelog:

  • 2017/10/04 @ 01h35 - Publicação do enunciado
  • 2017/10/07 @ 19h00 - Atualizada a secção da avaliação (com valores atribuídos aos diversos aspetos).
  • 2017/10/07 @ 19h00 - No final da sub-secção relativa às funções geradoras de formas foi ampliado o exemplo dado. Foi acrescentada uma nota de implementação.

Introdução

A dança das figuras rotativas...

Alguns efeitos visuais engraçados conseguem-se com princípios muito simples, replicados várias vezes. Um desses efeitos consiste na visualização simultânea de vários objetos semelhantes, mas com identidade própria, distribuídos pelo ecrã. A identidade própria de cada um pode ser dada pela sua posição, orientação, tamanho, cor, etc.

Eis alguns exemplos:

Projeto

Pretende-se construir uma aplicação que permita a instanciação de diversas figuras 2D no ecrã. Essas figuras podem ser figuras geométricas bem conhecidas (quadrados, círculos, anéis, cruzes, etc.), ou criações mais artísticas com padrões coloridos.

A ideia é permitir ao utilizador a colocação dum número arbitrário (mas limitado) dessas figuras no ecrã, ficando estas em movimento perpétuo de rotação. A aplicação deverá suportar um conjunto de formas distintas (#{} > 1), com cor e transparência escolhidas pelo utilizador por intermédio duma interface adequada.

Eis uma imagem que ilustra o estado da aplicação após a criação de algumas figuras e da sua colocação no canvas, ainda sem qualquer rotação:

Interface

A interface com o utilizador deverá permitir que este possa, em qualquer altura, acrescentar uma figura num local, efetuando para tal um click sobre o canvas no local pretendido. Essa figura passará a constar do conteúdo a mostrar pela aplicação, devendo a aplicação ser capaz de desenhar todas as figuras a ela adicionadas até ao momento.

A forma da figura a adicionar será escolhida pelo utilizador, ficando a sua seleção em efeito (forma corrente), até ser eventualmente alterada de futuro. O mesmo se passa relativamente à cor onde, para além das 3 componentes primárias (vermelho, verde e azul), se permite também a especificação da opacidade (valor alpha). A cor atual deverá ser mostrada em alguma zona da interface exclusiva para o efeito (p. ex. preenchendo o interior dum retângulo exibido na janela).

A velocidade (e sentido) da rotação específicos de cada instância duma figura, bem como a sua dimensão, deverão ser escolhidos aleatoriamente pela aplicação, sem qualquer especificação por parte do utilizador. Estes valores permanecerão constantes durante a execução do programa. Admite-se que a dimensão possa ser interpretada pela aplicação como sendo a dimensão máxima da figura, podendo o seu valor ser animado entre um valor mínimo e este valor máximo sorteado pela aplicação aquando da sua criação.

Eis uma imagem que mostra o estado do programa após algum tempo (com rotação das figuras):

Nas imagens acima apresentadas as figuras estão todas pintadas duma única cor sólida, sendo totalmente opacas, cobrindo na totalidade o fundo, tornando bem visível a sua fronteira. Com uma adequada manipulação do valor da opacidade (valor alpha) é possível atenuar as fronteiras dessas figuras. Atente-se na comparação seguinte:

Do lado esquerdo temos uma figura sem recurso a transparência/opacidade parcial sendo bem visíveis os seus contornos. Do lado direito, temos uma figura semelhante, mas agora com os contornos suaves, por utilização do valor alpha nessas regiões.

Modo automático

Paralelamente, a interface deverá permitir a ativação dum modo de funcionamento automático onde, a intervalos mais ou menos regulares de tempo (p.ex. a cada segundo), o programa acrescente uma nova figura numa localização aleatória. Essa figura terá, de igual modo, a sua velocidade e sentido de rotação, bem como o seu tamanho, definidos aleatóriamente aquando da sua criação. A cor, transparência e a forma da figura serão aquelas que o utilizador tiver escolhido no interface, permitindo-se que estes valores possam estar a ser manipulados ao vivo (à medida que vão sendo instanciadas novas figuras).

Eis alguns exemplos de composições que demonstram os resultados que se podem obter com a aplicação que se pretende desenvolver:

Detalhes técnicos

Geometria a desenhar

A única primitiva a desenhar neste trabalho será um conjunto de gl.POINTS, usando um vértice por cada local assinalado pelo utilizador ou criado aleatoriamente pela aplicação em modo automático.

Variável gl_PointSize

Normalmente, um ponto (desenhado com gl.POINTS) será mostrado no ecrã usando apenas um pixel. No entanto, é possível alterar o tamanho (em pixels) da região do ecrã que vai ser pintada por aquela primitiva. Para tal, basta escrever na variável gl_PointSize (disponível para escrita apenas no interior do vertex shader) o tamanho pretendido, em pixels, para o varrimento da primitiva gl.POINTS. Eis um exemplo dum vertex shader que, ao ser usado juntamente com a primitiva gl.POINTS, fará com que cada ponto seja desenhado usando um quadrado de 10x10 pixels:

attribute vec4 vPosition;

void main() {
    gl_Position = vPosition;
    gl_PointSize = 10.0;
}

Contudo, algumas implementações de WebGL, dependendo da combinação Browser/Sistema poderão limitar a gama de valores que se podem usar. Pode verificar a existência ou não de alguma limitação na sua plataforma usando esta página.

Variável gl_PointCoord

O que acontece quando gl_PointSize é superior a 1 pixel é que, durante a geração dos pixels correspondentes a cada ponto que se mandou desenhar, o rasterizer irá gerar um quadrado de pixels, em vez de apenas um. Como estamos a falar da primitiva gl.POINTS, com um vértice por cada ponto desenhado, todas as variáveis de tipo varying que possam ser escritas no vertex shader terão sempre o mesmo valor (como se fossem constantes) para todos os pixels gerados. Por oposição às primitivas do tipo linha ou triângulo, onde as variáveis varying produzem para cada pixel/fragmento um valor obtido por interpolação dos valores atribuídos em cada vértice da respetiva primitiva. Afinal de contas não faz sentido falar-se de interpolação quando apenas um valor é fornecido (o do vértice que dá origem ao ponto)!

Ora, para conseguirmos implementar uma forma geométrica dentro deste "quadrado de pixels" que agora vai ser desenhado por cada um dos gl.POINTS, será necessário podermos distinguir um pixel que se está a desenhar de outro, seu vizinho.

Felizmente, no interior do fragment shader temos acesso a uma variável que vai sendo interpolada no interior do "quadrado de pixels" e que fornece, assim, um valor distinto para cada pixel a ele pertencente. Trata-se da variável gl_PointCoord de tipo vec2.

A figura seguinte mostra como variam os valores de gl_PointCoord no interior do "quadrado de pixels" de dimensão gl_PointSize.

Note-se que esta figura corresponde à totalidade dos pixels que foram pintados para apenas um dos gl.POINTS. O fragment shader usado na produção da imagem foi o abaixo reproduzido:

precision mediump float;

void main() {
    gl_FragColor = vec4(gl_PointCoord, 0.0, 1.0);
}

Pela análise do código conclui-se facilmente que os valores de gl_PointCoord são (0,0) no canto superior esquerdo do quadrado desenhado e (1,1) no canto inferior direito.

Transformação de coordenadas

As coordenadas em que gl_PointCoord vem especificado não são as mais apropriadas para construirmos as nossas funções que irão implementar as formas pretendidas, sendo preferível trabalhar com um referencial com a origem centrada no quadrado e com os limites compreendidos entre -1 e 1, para ambas as componentes (x e y).

A seguinte função auxiliar, a colocar no fragment shader pode ser usada para converter gl_PointCoord para este referencial centrado no quadrado:

vec2 getPos(vec2 p)
{
    return vec2(2.0, -2.0) * p - vec2(1.0, -1.0);
}

Funções geradoras de formas

Vamos começar por ver como se pode implementar facilmente uma forma circular no fragment shader. Para tal precisamos introduzir a instrução discard. Durante a execução do fragment shader, temos a possibilidade de descartar um determinado pixel, tendo como consequência que esse mesmo pixel ficará por pintar. Assim sendo, uma forma para implementar uma figura em forma de círculo será descartando todos os pixels cuja distância ao centro exceda um valor convencionado.

No referencial que acabámos por sugerir trabalhar (ver a função getPos() atrás), podemos estipular que a nossa forma circular terá radio 1, estendendo-se assim até aos limites do quadrado, sem que com isso alguma parte do círculo seja recortada. Por outras palavras, um círculo de raio 1 (diâmetro 2) estará inscrito num quadrado de lado 2.

Usando então a instrução discard o nosso fragment shader para pintar círculos de cor constante poderia ser:

precision mediump float;

uniform vec4 color;
void main() {
    float dist = distance( vec2(0.0,0.0), getPos(gl_PointCoord) )
    if ( dist > 1.0 )
       discard;
    gl_FragColor = color;
}

Bom, agora é uma questão de imaginação para implementar outras formas geométricas ou até artísticas que se pretenda. Cada forma poderá estar implementada numa função isolada que devolverá a cor do pixel ou, no caso de sair fora da região definida por essa mesma forma, executará a instrução discard abortando com isso a execução do fragment shader, ficando esse pixel por pintar.

Exemplo:

precision mediump float;

uniform vec4 color;

vec2 getPos(vec2 p)
{
    return vec2(2.0, -2.0) * p - vec2(1.0, -1.0);
}

vec4 circle(vec4 clr) {
    float dist = distance( vec2(0.0,0.0), getPos(gl_PointCoord) )
    if ( dist > 1.0 )
       discard;
    return clr;
}


void main() {
    gl_FragColor = circle(color);   
}

Nota:

Em algumas máquinas, a simples colocação da instrução discard no ramo else dum teste condicional faz com que o shader deixe de produzir os resultados corretos. Assim, aconselha-se a usar o padrão ilustrado na função acima, onde a execução de discard é efetuada tão cedo quanto possível e sempre no ramo if em vez do ramo else.

Funções gl.bufferData() e gl.bufferSubData()

A função gl.bufferData() pode ser invocada quer passando um array de números de vírgula flutuante Float32Array como 2ª argumento, quer passando apenas um número inteiro, representando o tamanho do buffer que se pretende inicializar.

Enquanto no primeiro caso, o próprio sistema deduz o tamanho necessário, no segundo é o programador a informar o sistema do espaço que vai necessitar para o seu buffer.

Quando é que se usa uma forma e a outra? É simples. Se na altura da chamada os dados já estiverem disponíveis usa-se a primeira forma, caso contrário usa-se a segunda forma.

No nosso caso, como o utilizador vai acrescentanto figuras ao programa, queremos que o nosso buffer esteja previamente alocado (com uma dimensão máxima) e queremos posteriormente, à medida que as figuras forem sendo criadas, ir inserindo novos dados no buffer.

A função que permite escrever numa parte de um buffer é a função gl.bufferSubData(), mas para tal é necessário que o buffer tenho o espaço já reservado. Como é que se reserva esse espaço? Usando a função gl.bufferData() e indicando um tamanho (em bytes) em vez de um Float32Array.

Buffers de floats

Se estudarem a implementação da função flatten(), fornecida com a biblioteca MV.js, vão ver que o que função devolve é sempre um objeto do tipo Float32Array, criado usando new Float32Array(size). O valor de n representa aqui o número de elementos do array, sendo cada um deles um número de vírgula flutuante que ocupa 32 bits, ou seja 4 bytes.

Assim, quando se usa o resultado da função flatten() como argumento da chamada gl.bufferData(), o que se está a passar é um array de números de vírgula flutuante, num formato nativo da máquina, e não usando o tipo de dados do Javascript para representação de valores numéricos, com o nome Number.

O problema é que a função flatten() está preparada para conhecer argumentos de tipo vec2, vec3, vec4, mat2, mat3 e mat4, mas não argumentos do tipo Number.

Vejamos alguns exemplos de como passar um array de floats para gl.bufferData() ou para gl.bufferSubData():

var myValues = [1.0, 9.0, ..., 0.4]; // Exact values are irrelevant in this example
...

// the JS array myValues is now used to create a native floating point array...
gl.bufferData(..., new Float32Array(myValues), ...); 

E se apenas quisermos acresentar um float a um array já existente, numa determinada posição?

float myValue = 1.0; // some value here... 

// Put the value inside a JS array first, next create a new Float32Array from that single value array...
gl.bufferSubData(..., offset, new Float32Array([myValue]), ...);

Metodologia proposta

  • Construa um programa que receba os eventos de click no canvas e, após conversão das coordenadas de ecrã (em pixels), para coordenadas WebGL, acrescente um vértice por cada click a um buffer. Este buffer será usado para desenhar gl.POINTS. Use um vertex shader semelhante ao apresentado acima e um fragment shader que pinte todos os fragmentos da mesma cor (p.ex. vermelho).

  • Modifique o seu programa por forma a passar, para além da posição, um atributo adicional, de tipo gl.FLOAT, que seja usado para controlar o tamanho de cada ponto. Necessita acrescentar algo semelhante a:

    attribute float vSize;
    

    no início do vertex shader. Vá preenchendo, na aplicação javascript, um buffer com um valor aleatório que representa o tamanho de cada ponto. Faça as modificações necessárias para que esse novo atributo seja passado ao seu vertex shader. Use, de seguida, o valor do atributo vSize no seu vertex shader para ajustar a dimensão dos pontos gerados.

  • Acrescente um novo atributo, desta vez para controlar a velocidade (e o sentido) da rotação. A ideia é usar esse valor para multiplicar pelo tempo que entretanto decorreu, o qual será passado aos shaders sob a forma de uma variável uniform:

    uniform float time;
    

    Repare que fazer rodar uma figura um determinado ângulo alfa pode ser entendido como um transformação dos pontos que queremos pintar no pontos obtidos por rotação de -alfa (rotação inversa). Exemplo, quando se está a pintar um determinado ponto numa figura que sofreu, por exemplo, uma rotação de 90 graus, é como se estivéssemos a pintar o ponto da figura (não rodada) a que se chega rodando 90 graus no sentido inverso o ponto que se pretende pintar...

    Eis uma função que devolve uma matriz que implementa uma rotação dum determinado ângulo:

    mat2 rotate2d(float _angle){
        return mat2(cos(_angle),-sin(_angle), sin(_angle),cos(_angle));
    }
    

    A matriz devolvida pela função rotate2d() pode ser multiplicada à esqueda dum ponto p, obtendo-se como resultado o ponto p', rodado.

  • Talvez queira considerar condensar alguns dos atributos que estão associados a cada vértice num vetor. Por exemplo, pode guardar num vec3() um total de 3 valores escalares distintos. Por exemplo, velocidade angular, tamanho e um identificador de forma.

  • Adicione variedade de formas ao seu trabalho.

  • Adicione transparência como forma de atenuação de hard edges ou apenas como efeito visual/artístico. Para tal necessitará acrescentar as seguintes instruções na inicialização da sua aplicação:

    gl.enable(gl.BLEND);
    gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
    

    Nota: pesquise sobre o tópico "Compositing" ou "Alpha Blending".

Regras e Informação Adicional

Composição dos grupos

Os trabalhos práticos deverão ser realizados por grupos de 2 alunos dum mesmo turno prático. Qualquer exceção a esta regra, terá que ser devidamente justificada e autorizada pelo respetivo docente.

Entrega

Os detalhes relativos à entrega do trabalho estão no piazza, nesta página.

Avaliação

Os trabalhos serão avaliados pelo respetivo docente das aulas práticas e discutidos com os respetivos alunos em data a definir oportunamente.

Partindo do princípio que todas as funcionalidade requeridas são implementadas, os elementos diferenciadores da qualidade dos trabalhos serão, entre outros:

  • (3 valores) a variedade de figuras implementadas;
  • (1 valor) a utilização de transparência parcial para atenuar algumas formas (soft edges) e não apenas da instrução discard (hard edges);
  • (2 valores) a organização da interface e a facilidade na sua utilização;
  • (2 valores) a inexistência de situações anómalas (erros de execução, crashes, etc.);