Imitação de desenho à mão livre no exemplo do RoughJS

O RoughJS é uma pequena biblioteca de gráficos JavaScript (< 9KB ) que permite desenhar no estilo esboçado e manuscrito . Ele permite que você desenhe com <canvas>e com SVG. Neste post, quero responder à pergunta mais popular sobre o RoughJS: como funciona?


Um pouco de história


Fascinado pelas imagens de gráficos, diagramas e esboços desenhados à mão, eu, como um verdadeiro nerd, me perguntei: posso criar esses desenhos com a ajuda de um código, posso imitar o desenho com a maior precisão possível à mão, preservando a possibilidade de implementação de software? Decidi me concentrar nas primitivas - linhas, polígonos, elipses e curvas - para criar uma biblioteca inteira de gráficos 2D. Com base nisso, você pode criar bibliotecas e gráficos para desenhar gráficos e diagramas.

Depois de examinar brevemente a questão, encontrei um artigo de Joe Wood e seus colegas chamado Sketchy rendering para visualização de informações . As técnicas descritas nela tornaram-se a base da biblioteca, especialmente no desenho de linhas e elipses.

Em 2017, escrevi a primeira versão da biblioteca, que só funcionava no Canvas. Tendo resolvido o problema, perdi o interesse nele. Um ano depois, trabalhei muito com o SVG e decidi adaptar o RoughJS para trabalhar com o SVG. Também alterei a estrutura da API para torná-la mais simples e focada em primitivas simples de gráficos vetoriais. Eu falei sobre a versão 2.0 no Hacker News e de repente ela ganhou imensa popularidade. Em 2018, foi o segundo post mais popular do ShowHN .

Desde então, outras pessoas criaram coisas mais incríveis baseadas no RoughJS, por exemplo, Excalidraw , Why do Cats & Dogs ... , a biblioteca gráfica roughViz .

Agora vamos falar sobre algoritmos ...

Desigualdade


A base fundamental para a imitação de figuras manuscritas é o acaso. Quando desenhamos à mão, quaisquer duas formas serão um pouco diferentes. Ninguém desenha perfeitamente com precisão, então todos os pontos espaciais no RoughJS são ajustados para deslocamento aleatório. A magnitude da aleatoriedade é dada por um parâmetro numérico roughness.


Imagine um ponto Ae um círculo ao seu redor. Agora substitua por um Aponto aleatório dentro deste círculo. A área deste círculo de aleatoriedade é controlada por valor roughness.

Linhas


Linhas manuscritas nunca são retas e geralmente mostram curvatura na dobra (descrita aqui ). Nós randomizamos os dois pontos finais da linha com base na rugosidade. Em seguida, selecionamos mais dois pontos aleatórios aproximadamente a uma distância de 50% e 75% do final do segmento. Ao conectar esses pontos da curva, obtemos o efeito de flexão .


Ao desenhar à mão, as pessoas às vezes movem o lápis para frente e para trás ao longo da linha. Isso é necessário para tornar a linha mais brilhante ou simplesmente para corrigir a retidão da linha. Parece algo como isto:


Para adicionar um efeito superficial, o RoughJS desenha uma linha duas vezes. No futuro, pretendo tornar esse aspecto mais personalizável.

Olhe para esta superfície da tela. O parâmetro rugosidade altera a aparência das linhas:


No artigo original sobre tela, você pode se desenhar.

Ao desenhar à mão, linhas longas geralmente se tornam menos retas e mais curvas. Ou seja, a aleatoriedade das compensações para criar um efeito é uma função do comprimento e valor da linha randomness. No entanto, o dimensionamento dessa função não é adequado para linhas muito longas. Por exemplo, na imagem abaixo, quadrados concêntricos são desenhados com a mesma semente aleatória, ou seja, de fato, eles são uma figura aleatória, mas com uma escala diferente.


Você pode notar que as bordas dos quadrados externos parecem um pouco mais desiguais que as internas. Portanto, também adicionei um fator de amortecimento, dependendo do comprimento da linha. O coeficiente de atenuação é usado como uma função de etapa em vários comprimentos.


Elipses (e círculos)


Pegue uma folha de papel e desenhe alguns círculos o mais rápido possível em um movimento contínuo. Aqui está o que eu tenho:


Observe que os pontos inicial e final do loop nem sempre correspondem. O RoughJS tenta imitar isso, enquanto torna a aparência mais completa (a técnica é adaptada do artigo do giCenter ).

O algoritmo encontra os npontos da elipse onde né determinado pelo tamanho da elipse. Então cada ponto é randomizado por seu valor roughness. Em seguida, uma curva é desenhada através desses pontos. Para obter o efeito de extremidades desconectadas, os pontos do segundo ao último não coincidem com o primeiro ponto. Em vez disso, a curva conecta o segundo e o terceiro pontos.


Uma segunda elipse também é desenhada para que o loop seja mais fechado e tenha um efeito de esboço adicional.

No artigo original, você pode desenhar elipses em uma superfície de tela interativa. Varie a rugosidade e observe como a forma muda:


No caso de desenho de linha, alguns desses artefatos se tornam mais acentuados se alguma forma for dimensionada para tamanhos diferentes. Em uma elipse, esse efeito é mais perceptível porque a proporção é quadrática. Na imagem abaixo, todos os círculos têm a mesma forma, mas os externos parecem mais irregulares.


O algoritmo é ajustado automaticamente com base no tamanho da forma, aumentando o número de pontos no círculo ( n). Abaixo está o mesmo conjunto de círculos gerado usando o ajuste automático.


Preenchendo


Linhas pontilhadas são geralmente usadas para preencher formas desenhadas à mão . No caso de esboços à mão livre, as linhas nem sempre permanecem dentro do contorno das formas. Eles também são randomizados. A densidade, ângulo, largura da linha pode ser ajustada.


Os quadrados mostrados acima são fáceis de preencher, mas no caso de outras formas, todos os tipos de problemas podem ocorrer. Por exemplo, polígonos côncavos (nos quais os ângulos podem exceder 180 °) geralmente causam esses problemas:


De fato, a imagem acima é obtida de um relatório de erro em uma das versões anteriores do RoughJS. Desde então, atualizei o algoritmo de preenchimento de traços, adaptando a versão do método de varredura de strings .

O algoritmo de varredura de cadeia pode ser usado para preencher qualquer polígono. Seu princípio é digitalizar um polígono usando linhas horizontais (linhas raster). As linhas de varredura vão do topo do polígono para baixo. Para cada linha raster, determinamos em que pontos a linha cruza com o polígono. Construímos esses pontos de interseção da esquerda para a direita.


Indo de um ponto a outro, alternamos do modo de preenchimento para o modo de não preenchimento; a alternância entre estados ocorre quando cada ponto de interseção na linha de varredura se encontra. Aqui, muito mais precisa ser levado em consideração, em particular casos de fronteira e métodos de otimização de varredura; Você pode ler mais sobre isso aqui: Rasterização de polígonos ou implantar um spoiler com pseudocódigo.

Detalhes da implementação do algoritmo de varredura de cadeias
() .

— (Edge Table, ET), , Ymin. Ymin, Xmin.

— (Active Edge Table, AET), , .

:

interface EdgeTableEntry {
  ymin: number;
  ymax: number;
  x: number; // Initialized to Xmin
  iSlope: number; // Inverse of the slope of the line: 1/m
}

interface ActiveEdgeTableEntry {
  scanlineY: number; // The y value of the scanline
  edge: EdgeTableEntry;
}

, :

1. y y ET. .

2. AET .

3. , AET, ET :

(a) ET y AET , ymin ≤ y.

(b) AET , y = ymax, AET x.

() y, x AET.

(d) y , , .. .

(e) , AET, x y (edge.x = edge.x + edge.iSlope)

Na imagem abaixo (no artigo original interativo), cada quadrado indica um pixel. Você pode mover os vértices para alterar o polígono e observar quais pixels serão preenchidos tradicionalmente.


Ao preencher traçados, o incremento de linhas de varredura é realizado em incrementos, dependendo da densidade de linhas de traçados, e cada linha é desenhada usando o algoritmo descrito acima.

No entanto, esse algoritmo é para linhas de varredura horizontais. Para implementar diferentes ângulos de traços, o algoritmo primeiro gira a própria forma pelo ângulo desejado de traços. Em seguida, as linhas de varredura da figura girada são calculadas. Além disso, as linhas calculadas giram de volta para o ângulo dos traços na direção oposta.


Não apenas preenchendo traços


O RoughJS também suporta outros estilos de preenchimento, mas todos são derivados do mesmo algoritmo de hachura. A hachura cruzada consiste em desenhar linhas tracejadas em ângulo anglee, em seguida, outras linhas em ângulo angle + 90°. O zigue-zague procura conectar uma linha tracejada à anterior. Para obter um padrão de pontos , desenhe pequenos círculos ao longo das linhas tracejadas.


As curvas


Tudo no RoughJS é normalizado para curvas - linhas, polígonos, elipses etc. Portanto, o desenvolvimento natural dessa idéia é criar uma curva de esboço. No RoughJS, passamos um conjunto de pontos para uma curva, após o que usamos a aproximação da curva para convertê-los em curvas cúbicas de Bezier .

Cada curva de Bezier possui dois pontos finais e dois pontos de controle. Ao randomizá-los com base roughness, você também pode criar curvas "manuscritas".


Enchimento da curva


No entanto, o processo inverso é necessário para preencher as curvas. Em vez de normalizar tudo para uma curva, a curva normaliza para um polígono. Após obter o polígono, você pode usar o algoritmo de varredura de linha para preencher a forma curva.

Você pode amostrar pontos na curva com a frequência desejada usando a equação da curva cúbica de Bezier .


Se usarmos a frequência de amostragem, que depende da densidade dos traços, obteremos pontos suficientes para preencher a figura. Mas isso não é particularmente eficaz. Se parte da curva é nítida, precisamos de mais pontos. Se parte da curva é quase reta, menos pontos são necessários. Uma solução pode ser determinar a curvatura / suavidade da curva. Se for muito curva, dividimos a curva em duas curvas menores. Se for suave, consideraremos simplesmente como uma linha reta.

A suavidade da curva é calculada usando o método descrito neste post . O valor da suavidade é comparado ao valor da tolerância, após o qual é tomada a decisão de dividir a curva ou não.

Aqui está a mesma curva com um nível de tolerância de 0,7:


Com base apenas na tolerância, o algoritmo fornece pontos suficientes para representar a curva. No entanto, ele não permite que você efetivamente se livre de pontos opcionais. Isso ajudará o segundo parâmetro chamado distância . Para reduzir o número de pontos nesse método, o algoritmo de Ramer-Douglas-Pecker é usado .

A seguir mostra os pontos gerados com valores de distância, igual a 0.15, 0.75, 1.5e 3.0.


Com base na rugosidade da forma, você pode definir o valor da distância apropriado . Tendo recebido todos os vértices do polígono, podemos preencher lindamente as formas curvas:


Circuitos SVG


Os contornos SVG são uma ferramenta muito poderosa que pode ser usada para criar todos os tipos de imagens impressionantes, mas, por causa disso, é bastante difícil trabalhar com elas.

O RoughJS analisa o caminho e o normaliza em apenas três operações: Mover , Linha e Curva Cúbica . ( analisador de dados do caminho ). Após a normalização, a figura pode ser desenhada usando os métodos acima para desenhar linhas e curvas.

O pacote de pontos no caminho combina a normalização de caminhos e a amostragem de pontos de curva para calcular os pontos de caminho correspondentes.

A seguir, é apresentado um exemplo de cálculo de ponto para um caminho M240,100c50,0,0,125,50,100s0,-125,50,-150s175,50,50,100s-175,50,-300,0s0,-125,50,-100s0,125,50,150s0,-100,50,-100:


Outro exemplo SVG que eu amo mostrar é o mapa geral dos Estados Unidos:


Experimente o RoughJS


Confira o site ou o repositório no Github ou a documentação da API . Siga o Twitter @RoughLib para obter informações sobre o projeto .

All Articles