Lição 9 - Desenhar elipse e retângulo
Nesta lição, você vai aprender o seguinte:
- Derivar as representações SDF da elipse e do retângulo arredondado
- Adicionar sombras projetadas e internas ao retângulo arredondado e a outros SDFs
- Determinar se um ponto qualquer está dentro de uma elipse ou de um retângulo arredondado
Na Lição 2, usamos SDFs para desenhar círculos, e é fácil estender isso à elipse e ao retângulo. 2D distance functions oferece mais expressões de SDF para gráficos 2D:
float sdf_ellipse(vec2 p, vec2 r) {}
float sdf_rounded_box(vec2 p, vec2 b, vec4 r) {}No Shader, use a variável shape para distinguir essas três formas, para podermos desenhá-las com o mesmo conjunto de Shaders:
if (shape < 0.5) {
outerDistance = sdf_circle(v_FragCoord, 1.0);
innerDistance = sdf_circle(v_FragCoord, r.x);
} else if (shape < 1.5) {
outerDistance = sdf_ellipse(v_FragCoord, vec2(wh, 1.0));
innerDistance = sdf_ellipse(v_FragCoord, r);
} else if (shape < 2.5) {
outerDistance = sdf_rounded_box(v_FragCoord, vec2(wh, 1.0), 0.0);
innerDistance = sdf_rounded_box(v_FragCoord, r, 0.0);
}Em seguida, vamos ver como o SDF é derivado.
Retângulo
The SDF of a Box e Leveraging Rust and the GPU to render user interfaces at 120 FPS demonstraram o processo de derivação em forma de vídeos e animações.
Com base na simetria do retângulo, ao colocar o centro na origem, podemos transformar o problema da distância de um ponto qualquer até a borda do retângulo para o primeiro quadrante usando a função abs(), em que p é a coordenada de um ponto qualquer, b é a coordenada do canto superior direito do retângulo e, como o centro coincide com a origem, vale [width / 2, height / 2], e q representa o vetor do ponto até o canto superior direito:
float sdf_box(vec2 p, vec2 b) {
vec2 q = abs(p) - b;
}
Em seguida, considere o caso em que o ponto está fora do retângulo. Estendendo para fora a partir do canto superior direito do retângulo, a região pode ser dividida em quatro quadrantes. No primeiro quadrante mostrado na figura abaixo, a distância é o comprimento do vetor q, length(q). Se o ponto cai no segundo quadrante, a distância é q.y, porque q.x é negativo e, da mesma forma, se cai no quarto quadrante, a distância é q.x.

O autor original usou com habilidade length(max(q, 0.0)) para unificar esses três casos, usando max() para eliminar os componentes negativos e reduzir ao máximo o julgamento de ramificação no Shader. Depois, considere o caso em que o ponto está dentro do retângulo, isto é, no terceiro quadrante da figura acima. Nesse momento, os dois componentes de q são negativos, max(q.x, q.y) obtém o valor absoluto da distância mais próxima, e o min() mais externo unifica o caso em que o ponto está dentro com os três anteriores, ainda para reduzir o julgamento de ramificação. O SDF completo é o seguinte:
float sdf_box(vec2 p, vec2 b) {
vec2 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
}Até aqui, podemos desenhar retângulos e, consultando SVG <rect>, adicionar as propriedades a seguir:
const rect = new Rect({
x,
y,
width,
height,
fill,
});Adicionar cantos arredondados
A figura a seguir vem de Rounding Corners in SDFs. Se observarmos o campo de distância visualizado por linhas de contorno, podemos perceber que o próprio retângulo é arredondado. Tomando como exemplo os pontos próximos ao canto superior direito do retângulo, não existe apenas um ponto com a mesma distância: eles se distribuem no círculo que tem o canto superior direito como centro.

Na verdade, não só retângulos, mas todos os gráficos representados por SDFs podem ser convertidos em versões “arredondadas”. A figura a seguir vem de 2D distance functions:
float opRound( in vec2 p, in float r ) {
return sdShape(p) - r;
}
Então, a representação SDF completa do retângulo arredondado é a seguinte:
float sdf_rounded_box(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}Seguindo a nomenclatura do Figma, usamos cornerRadius, mas o SDF sozinho não consegue o efeito de smoothing. Veja os detalhes em Adjust corner radius and smoothing e Desperately seeking squircles. Além disso, também é possível oferecer raios de canto diferentes para cada canto; consulte Zed Blade WGSL, um Shader escrito pelo Zed com base no renderizador blade, usando a sintaxe WGSL.
$icCanvas2 = call(() => {
return document.createElement('ic-canvas-lesson9');
});call(() => {
const { Canvas, Rect } = Lesson9;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas2.parentElement.style.position = 'relative';
$icCanvas2.parentElement.appendChild($stats);
$icCanvas2.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
for (let i = 0; i < 1000; i++) {
const fill = `rgb(${Math.floor(Math.random() * 255)},${Math.floor(
Math.random() * 255,
)},${Math.floor(Math.random() * 255)})`;
const rect = new Rect({
x: Math.random() * 1000,
y: Math.random() * 1000,
fill,
cornerRadius: 10,
});
// rect.x = Math.random() * 1000;
// rect.y = Math.random() * 1000;
rect.width = Math.random() * 40;
rect.height = Math.random() * 40;
canvas.appendChild(rect);
rect.addEventListener('pointerenter', () => {
rect.fill = 'red';
});
rect.addEventListener('pointerleave', () => {
rect.fill = fill;
});
}
});
$icCanvas2.addEventListener('ic-frame', (e) => {
stats.update();
});
});Adicionar sombra projetada
Quando se fala em sombras, você pode ter ouvido falar de box-shadow e de filter: drop-shadow() no CSS. A figura a seguir vem do artigo Drop-Shadow: The Underrated CSS Filter, que mostra de forma intuitiva a diferença entre os dois:

O segundo costuma ser mais usado, como em tailwindcss - Drop Shadow. Então adicionamos as propriedades a seguir ao retângulo:
rect.dropShadowColor = 'black';
rect.dropShadowOffsetX = 10;
rect.dropShadowOffsetY = 10;
rect.dropShadowBlurRadius = 5;
Em seguida, vamos usar WebGL / WebGPU para desenhar sombras de gráficos 2D. A abordagem usual é usar desfoque gaussiano no pós-processamento, como o DropShadowFilter do Pixi.js. O efeito de desfoque gaussiano 2D pode ser decomposto em dois efeitos 1D, para processamento horizontal e vertical, mas a operação de convolução ainda exige a amostragem de pixels vizinhos (dependendo do tamanho do núcleo de convolução).
O CTO do Figma, Evan Wallace, apresentou um método de aproximação mais rápido no artigo Fast Rounded Rectangle Shadows, que não exige amostragem de texturas, e o artigo Leveraging Rust and the GPU to render user interfaces at 120 FPS também trouxe uma introdução mais detalhada. A convolução da função gaussiana com a função degrau equivale à integral da função gaussiana, o que resulta na função erro Error function (também conhecida como erf). Portanto, gerar um retângulo desfocado equivale a desfocar cada dimensão separadamente e depois tomar a interseção dos dois resultados, sem considerar o raio dos cantos.
Função gaussiana:
A função erro é a integral de uma função gaussiana, usada para descrever a função de distribuição acumulada de uma distribuição normal.
Uma aproximação comum da função erro vem de Abramowitz and Stegun. Handbook of Mathematical Functions.
em que os coeficientes dos termos do polinômio são:
A implementação a seguir vem de Zed Blade WGSL, que reescrevemos em GLSL. Blurred rounded rectangles também apresenta outra versão da erf.
vec2 erf(vec2 x) {
vec2 s = sign(x), a = abs(x);
x = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a;
x *= x;
return s - s / (x * x);
}Deixe os cantos arredondados de lado por enquanto e calcule o valor final da máscara de sombra. Aqui o limite da sombra é determinado pela diferença entre integral_x e integral_y. integral_x.x - integral_x.y calcula a largura da sombra no eixo x e integral_y.x - integral_y.y calcula a altura da sombra no eixo y. Multiplique esses dois valores para obter o valor final da máscara de sombra.
// Return the mask for the shadow of a box from lower to upper
float rect_shadow(vec2 pixel_position, vec2 origin, vec2 size, float sigma) {
vec2 bottom_right = origin + size;
vec2 x_distance = vec2(pixel_position.x - origin.x, pixel_position.x - bottom_right.x);
vec2 y_distance = vec2(pixel_position.y - origin.y, pixel_position.y - bottom_right.y);
vec2 integral_x = 0.5 + 0.5 * erf(x_distance * (sqrt(0.5) / sigma));
vec2 integral_y = 0.5 + 0.5 * erf(y_distance * (sqrt(0.5) / sigma));
return (integral_x.x - integral_x.y) * (integral_y.x - integral_y.y);
}Uma solução de forma fechada como a de cima, porém, não existe para a convolução 2D de um retângulo arredondado com uma gaussiana, porque a fórmula de um retângulo arredondado não é separável. A sagacidade da aproximação de Evan Wallace está em fazer uma convolução exata, de forma fechada, ao longo de um eixo e, em seguida, deslizar manualmente a gaussiana ao longo do eixo oposto um número finito de vezes:
float blur_along_x(float x, float y, float sigma, float corner, vec2 half_size) {
float delta = min(half_size.y - corner - abs(y), 0.);
float curved =
half_size.x - corner + sqrt(max(0., corner * corner - delta * delta));
vec2 integral =
0.5 + 0.5 * erf((x + vec2(-curved, curved)) * (sqrt(0.5) / sigma));
return integral.y - integral.x;
}// The signal is only non-zero in a limited range, so don't waste samples
float low = center_to_point.y - half_size.y;
float high = center_to_point.y + half_size.y;
float start = clamp(-3. * blur_radius, low, high);
float end = clamp(3. * blur_radius, low, high);
// Accumulate samples (we can get away with surprisingly few samples)
float step = (end - start) / 4.;
float y = start + step * 0.5;
for (int i = 0; i < 4; i++) {
alpha += blur_along_x(center_to_point.x, center_to_point.y - y, blur_radius,
cornerRadius, half_size) *
gaussian(y, blur_radius) * step;
y += step;
}Na implementação, as sombras precisam ser desenhadas individualmente para cada retângulo, o que quebra o efeito do lote combinado anterior. O motivo é que precisamos seguir uma ordem de desenho estrita, reordenando até mesmo antes de cada redesenho. O código a seguir vem deste artigo: Fast Rounded Rectangle Shadows. Antes de desenhar, é preciso ordenar todos os retângulos por uma profundidade predefinida e, então, desenhar as sombras e o corpo em sequência:
render() {
boxes.sort(function(a, b) {
return a.depth - b.depth;
});
for (var i = 0; i < boxes.length; i++) {
boxes[i].callback(); // Draw shadow first and then the rectangle itself.
}
}Como exemplo, os dois retângulos a seguir são desenhados nesta ordem: sombra do retângulo verde, retângulo verde, sombra do retângulo vermelho, retângulo vermelho. Se seguirmos a ideia anterior de mesclar as duas sombras e os dois corpos dos retângulos em dois lotes separados de desenho, não conseguiremos fazer as sombras dos retângulos vermelhos serem projetadas sobre os retângulos verdes. Então, ao usar, precisamos definir batchable = false para os retângulos com sombras
$icCanvas3 = call(() => {
return document.createElement('ic-canvas-lesson9');
});call(() => {
const { Canvas, Rect } = Lesson9;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas3.parentElement.style.position = 'relative';
$icCanvas3.parentElement.appendChild($stats);
$icCanvas3.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
const rect = new Rect({
x: 50,
y: 50,
fill: 'green',
cornerRadius: 50,
batchable: false,
dropShadowColor: 'black',
dropShadowOffsetX: 10,
dropShadowOffsetY: 10,
dropShadowBlurRadius: 10,
});
rect.width = 400;
rect.height = 100;
canvas.appendChild(rect);
const rect2 = new Rect({
x: 100,
y: 100,
fill: 'red',
batchable: false,
cornerRadius: 50,
dropShadowColor: 'black',
dropShadowBlurRadius: 10,
});
rect2.width = 400;
rect2.height = 100;
canvas.appendChild(rect2);
});
$icCanvas3.addEventListener('ic-frame', (e) => {
stats.update();
});
});Mais um ponto a observar é que, por causa do raio de desfoque da sombra, é preciso fazer o retângulo se expandir para fora do tamanho original, o que aqui é definido como 3 * dropShadowBlurRadius.
float margin = 3.0 * dropShadow.z;
origin += dropShadow.xy;
v_Origin = origin;
v_Size = size;
origin -= margin;
size += 2.0 * margin;
vec2 center = origin + size / 2.0;
v_Point = center + a_FragCoord * (size / 2.0);Por fim, as sombras também afetam o cálculo de RenderBounds; caso contrário, os retângulos serão rejeitados incorretamente quando o corpo estiver fora do viewport, mas as sombras estiverem dentro:
this.renderBounds.addBounds(
new AABB(
x + dropShadowOffsetX - dropShadowBlurRadius,
y + dropShadowOffsetY - dropShadowBlurRadius,
x + dropShadowOffsetX + width + dropShadowBlurRadius,
y + dropShadowOffsetY + height + dropShadowBlurRadius,
),
);Com base nessa abordagem, também é possível realizar alguns efeitos interessantes; veja: Shape Lens Blur Effect with SDFs and WebGL.
É evidente que o método acima só funciona para retângulos arredondados, mas existe um método mais geral para círculos, elipses e outras representações SDF? Há um exemplo no Shader toy: Drop shadow of rounded rect e, curiosamente, há outro exemplo baseado nesse que permite implementações tanto de sombra externa quanto de sombra interna. Abaixo, nos concentramos na implementação da sombra interna.
Adicionar sombra interna
A imagem abaixo mostra o efeito de sombra interna do Figma, muito usado em componentes de UI como o Button.

Vamos adicionar o atributo a seguir:
rect.innerShadowColor = 'black';
rect.innerShadowOffsetX = 10;
rect.innerShadowOffsetY = 10;
rect.innerShadowBlurRadius = 5;Consultando o exemplo no Shader toy: Inner shadow of rounded rect, adicionamos de forma parecida a lógica de desenho de sombra para cada uma das três formas atuais. Usamos sigmoid aqui para suavizar:
float make_shadow(vec2 pos, vec2 halfSize, float cornerRd, float blurRd, float distMul, float shape) {
float distance;
if (shape < 0.5) {
distance = sdf_circle(pos, halfSize.x);
} else if (shape < 1.5) {
distance = sdf_ellipse(pos, halfSize);
} else if (shape < 2.5) {
distance = sdf_rounded_box(pos, halfSize, cornerRd + blurRd);
}
float dist = sigmoid(distMul * distance / blurRd);
return clamp(dist, 0.0, 1.0);
}$icCanvas4 = call(() => {
return document.createElement('ic-canvas-lesson9');
});call(() => {
const { Canvas, Rect, Circle, Ellipse } = Lesson9;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas4.parentElement.style.position = 'relative';
$icCanvas4.parentElement.appendChild($stats);
$icCanvas4.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
for (let i = 0; i < 10; i++) {
const fill = `rgb(${Math.floor(Math.random() * 255)},${Math.floor(
Math.random() * 255,
)},${Math.floor(Math.random() * 255)})`;
const rect = new Rect({
x: Math.random() * 1000,
y: Math.random() * 1000,
fill,
cornerRadius: 50,
innerShadowColor: 'black',
innerShadowOffsetX: Math.random() * 20 - 10,
innerShadowOffsetY: Math.random() * 20 - 10,
innerShadowBlurRadius: Math.random() * 10,
});
rect.width = 200;
rect.height = 100;
canvas.appendChild(rect);
const circle = new Circle({
cx: Math.random() * 1000,
cy: Math.random() * 1000,
r: 100,
fill,
innerShadowColor: 'black',
innerShadowOffsetX: Math.random() * 20 - 10,
innerShadowOffsetY: Math.random() * 20 - 10,
innerShadowBlurRadius: Math.random() * 10,
});
canvas.appendChild(circle);
const ellipse = new Ellipse({
cx: Math.random() * 1000,
cy: Math.random() * 1000,
rx: 100,
ry: 50,
fill,
innerShadowColor: 'blue',
innerShadowOffsetX: Math.random() * 20 - 10,
innerShadowOffsetY: Math.random() * 20 - 10,
innerShadowBlurRadius: Math.random() * 10,
});
canvas.appendChild(ellipse);
}
});
$icCanvas4.addEventListener('ic-frame', (e) => {
stats.update();
});
});Elipse
Ao contrário de determinar a menor distância de um ponto arbitrário até um círculo, o método exato de analisar uma elipse é bem mais complexo, e Distance to an ellipse apresenta dois métodos: a equação quártica e o método de Newton.
Equação quártica
Uma elipse é representada por
Desdobrada:
Derivada em relação a
A derivada no ponto mais próximo é 0, obtida ao introduzir
Esta é uma equação quártica de uma variável em
Para reduzir os coeficientes do termo de quarto grau a 1 e obter Monic Polynomial, divida cada um dos coeficientes acima por
Isso produz Quartic Equation com os coeficientes a seguir:
Como o coeficiente de maior grau é 1, isso pode ser transformado em um problema de resolver uma equação cúbica Cubic Formula, usando Resolvent Cubic:
em que os coeficientes correspondem a:
A substituição dá:
Resolver Cubic Formula envolve tentar eliminar os coeficientes binomiais, o que pode ser feito com
Substituir
Continuando a simplificar a equação acima, seja:
A forma padronizada passa a ser:
Esse método tem custo computacional alto, e o exemplo escrito no ShaderToy mostrado em Distance to an ellipse tem um FPS bem menor do que os dois métodos descritos abaixo.
Método de Newton
O vetor formado por um ponto qualquer
Em que:
A substituição dá:
Newton's method, que é um método para resolver equações de forma aproximada nos domínios dos números reais e complexos.
A substituição dá:
Distance to an ellipse O autor original usou 5 iterações para alcançar um resultado muito bom:
for (int i=0; i<5; i++) {
vec2 cs = vec2(cos(w),sin(w));
vec2 u = ab*vec2( cs.x,cs.y);
vec2 v = ab*vec2(-cs.y,cs.x);
w = w + dot(p-u,v)/(dot(p-u,u)+dot(v,v));
}Tudo o que falta abaixo é encontrar
bool s = dot(p/ab,p/ab)>1.0;Se estiver fora da elipse, ela pode ser esticada até virar um círculo (o método descrito na subseção seguinte também será usado), em que
Se estiver dentro da elipse, escolha o semieixo maior
float w = s ? atan(p.y*ab.x, p.x*ab.y) :
((ab.x*(p.x-ab.x)<ab.y*(p.y-ab.y))? 1.5707963 : 0.0);Em comparação com o método anterior de resolver uma equação quártica de uma variável, este método tem menos custo. Mas, afinal, ele ainda contém um laço for com um número fixo de iterações, que é desenrolado ao compilar GLSL; veja: Loop performance in a shader. Por fim, apresentaremos um método de estimativa aproximada com o menor custo.
Método aproximado de estiramento
Ellipsoid SDF descreve esse método de esticar até um círculo unitário e depois recuperar a elipse. Mas como você escolhe os coeficientes de estiramento na recuperação? Se o ponto está no eixo X, o coeficiente é r.x, e no eixo Y é r.y, e o menor dos dois é escolhido primeiro:
float sdf_ellipse_V1( in vec2 p, in vec2 r )
{
float k1 = length(p/r);
return (k1-1.0)*min(r.x,r.y);
}A renderização real revela que essa abordagem tem problemas significativos no eixo maior e não se ajusta bem:

O autor original apresentou uma versão melhorada:
float sdf_ellipse_V3( in vec3 p, in vec3 r )
{
float k1 = length(p/r);
return length(p)*(1.0-1.0/k1);
}Porém, a renderização real revela que essa abordagem tem um serrilhado significativo nas bordas:

Por fim, os autores acrescentaram a consideração dos gradientes, que também usamos:
float sdf_ellipse_V2( in vec2 p, in vec2 r )
{
float k1 = length(p/r);
float k2 = length(p/(r*r));
return k1*(k1-1.0)/k2;
}Você pode ver que os resultados ficam bem melhores.
$icCanvas = call(() => {
return document.createElement('ic-canvas-lesson9');
});call(() => {
const { Canvas, Ellipse } = Lesson9;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas.parentElement.style.position = 'relative';
$icCanvas.parentElement.appendChild($stats);
$icCanvas.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
for (let i = 0; i < 1000; i++) {
const fill = `rgb(${Math.floor(Math.random() * 255)},${Math.floor(
Math.random() * 255,
)},${Math.floor(Math.random() * 255)})`;
const ellipse = new Ellipse({
cx: Math.random() * 1000,
cy: Math.random() * 1000,
rx: Math.random() * 20,
ry: Math.random() * 20,
fill,
});
canvas.appendChild(ellipse);
ellipse.addEventListener('pointerenter', () => {
ellipse.fill = 'red';
});
ellipse.addEventListener('pointerleave', () => {
ellipse.fill = fill;
});
}
});
$icCanvas.addEventListener('ic-frame', (e) => {
stats.update();
});
});Picking
Atualmente, o nosso plugin de picking usa o método geométrico, então precisamos implementar métodos de determinação separados para elipses e retângulos arredondados, e mais adiante apresentaremos um método mais geral de picking baseado em GPU com codificação de cor. Nos dois exemplos acima, você pode passar o mouse sobre qualquer forma para experimentar o efeito de picking.
Elipse
As decisões de picking da elipse são mais simples; por exemplo, as áreas de preenchimento e de traço são levadas em conta por padrão:
function isPointInEllipse(
x: number,
y: number,
h: number,
k: number,
a: number,
b: number,
) {
const dx = x - h;
const dy = y - k;
const squaredDistance = (dx * dx) / (a * a) + (dy * dy) / (b * b);
// Inside ellipse
return squaredDistance <= 1;
}Retângulo arredondado
Também é bem simples se os cantos arredondados não forem considerados.
function isPointInRoundedRectangle(
x: number,
y: number,
x1: number,
y1: number,
x2: number,
y2: number,
r: number,
) {
// Determine if a point is inside the corners of a rectangle.
function isInsideCorner(
x: number,
y: number,
cornerX: number,
cornerY: number,
r: number,
) {
const distance = Math.sqrt(
Math.pow(x - cornerX, 2) + Math.pow(y - cornerY, 2),
);
return distance <= r;
}
// Determine if a point is inside a rounded rectangle
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
// Points inside the rectangle
if (
isInsideCorner(x, y, x1 + r, y1 + r, r) || // top-left
isInsideCorner(x, y, x2 - r, y1 + r, r) || // top-right
isInsideCorner(x, y, x2 - r, y2 - r, r) || // bottom-right
isInsideCorner(x, y, x1 + r, y2 - r, r) // bottom-left
) {
return true; // Points inside the corner
}
return !(
x <= x1 + r ||
x >= x2 - r || // The point is on the non-circular boundary of the rectangle
y <= y1 + r ||
y >= y2 - r
);
}
return false; // Points outside the rectangle
}