Pular para o conteúdo
Forma · Composição de SDF 2DIntermediário

Botão de interface arredondado

Um botão em forma de pílula com borda interna, um ícone de seta (chevron) e uma sombra projetada suave, todos recortados de distâncias com sinal.

UVSuperfície
Abrir no Playground
  1. 01ObjetivoForma
  2. 02TécnicaComposição de SDF 2D
  3. 03ExecuçãoBotão de interface arredondado
Quad de tela cheia

Parâmetros

0.75
0.25
0.12
0.020
0.10
#455cf2
#8c9eff
#ffffff

Copie para a sua plataforma

  • GLSLCopiar
  • GodotCopiar

Cadeia de nós

Montada passo a passo, no estilo do GLSL, com os nomes de nós dele.

Montada passo a passo, no estilo do Godot, com os nomes de nós dele.

Este card ainda não tem versão para Unreal Engine 5, então os nós mostram os nomes do GLSL.

Este card ainda não tem versão para Unity, então os nós mostram os nomes do GLSL.

Este card ainda não tem versão para Blender, então os nós mostram os nomes do GLSL.

  1. lengthExpressionlengthlengthlength
    UV

    01 Distância da caixa arredondada

  2. maxMaxmaxmaxmax

    02 Subtrair: borda interna

  3. minMinminminmin

    03 União: seta

  4. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    04 Sombra suave

  5. mixMixmixmixmix

    05 Empilhar as cores

  6. fwidthDerivativeFuncfwidthfwidthfwidth

    06 Borda sem serrilhado

Como funciona

Um botão é uma pequena pilha de formas: um corpo, uma borda, um ícone e uma sombra. Desenhar cada uma como imagem significa quatro imagens que borram em outro tamanho. Desenhá-las como distâncias com sinal significa quatro fórmulas curtas, combinadas com os operadores da composição de SDF, e toda borda continua com exatamente um pixel de suavidade em qualquer tamanho.

O corpo: uma caixa arredondada. As coordenadas são centralizadas e corrigidas pela proporção da tela, como nos cards de Forma anteriores. sdRoundBox primeiro mede a distância até uma caixa encolhida pelo raio do canto e depois subtrai o raio do resultado. Subtrair uma constante faz um campo de distância crescer para fora nessa medida, e fazer um canto vivo crescer r o transforma num quarto de círculo de raio r; assim, a caixa volta ao tamanho cheio com os cantos arredondados. O raio é limitado à menor das meias medidas, o que transforma um botão baixo e largo numa pílula em vez de quebrar a forma.

A borda: uma subtração. body + borderWidth é a mesma caixa encolhida para dentro pela espessura da borda. Subtrair uma forma de outra é max(a, -b), então max(body, -(body + borderWidth)) guarda a parte do corpo que não está dentro da cópia menor: um contorno que acompanha o lado de dentro da borda, com os mesmos cantos arredondados.

O ícone: uma união. Cada braço da seta é a distância até um segmento de reta, que projeta o pixel sobre o segmento e mede até o ponto mais próximo. O min() das duas distâncias é os dois segmentos ao mesmo tempo, e subtrair uma espessura faz as linhas crescerem até virar cápsulas de pontas redondas. O tamanho do ícone acompanha a altura do botão, então ele escala junto.

A sombra: a mesma distância, borrada. A fórmula do corpo é calculada mais uma vez num ponto deslocado para cima, o que desloca a forma para baixo, e recortada com um smoothstep largo, em vez de um de um pixel. Uma rampa larga sobre uma distância é um desfoque de graça: nenhuma amostra de pixels vizinhos, só um limiar mais suave. A intensidade fica em 0,45 para ela parecer uma sombra, e não um segundo botão.

Empilhando as cores. Cada distância vira uma cobertura com a mesma função auxiliar, fill, e as coberturas escolhem as cores com mix na ordem do pintor: a borda sobre a face, o ícone sobre as duas.

Uma borda de um pixel. fill não usa uma suavidade fixa. fwidth(d) é quanto a distância muda entre este pixel e os vizinhos, então é o tamanho de um pixel nas unidades da própria distância. Um smoothstep de menos metade a mais metade disso é uma borda sem serrilhado de um pixel, por mais que o botão seja escalado ou ampliado. (As derivadas têm o próprio fundamento.)

Alfa. O botão é desenhado com alpha blending sobre o que estiver atrás dele. O alfa é a cobertura do corpo mais a sombra onde não há corpo; como a sombra é preta, ela soma opacidade mas não cor, e a cor é dividida pelo alfa porque a moldura espera alfa direto, não pré-multiplicado.

As mesmas distâncias dão mais visuais com uma linha cada: um brilho de hover é exp(-k * max(body, 0.0)), um estado pressionado aproxima a sombra, e um anel de foco é abs(body - gap) - width.

Monte no GLSL

O código copiado é um shader de fragmento WebGL2 completo que desenha o botão, com a sombra, sobre o que já estiver no canvas.

  1. Desenhe um retângulo onde o botão vai ficar, maior que o botão no tamanho da sombra, com um shader de vértice que escreva v_uv de 0 a 1 ao longo dele e constantes em v_position, v_normal e v_view.
  2. Defina u_resolution com o tamanho, em pixels, da área que as coordenadas do shader cobrem. O código centraliza as coordenadas no retângulo e as corrige pela proporção de u_resolution, então, para um retângulo menor que o canvas, passe o tamanho do próprio retângulo em pixels.
  3. Defina os cinco uniforms float (a meia largura e a meia altura estão em unidades de metade da altura do retângulo) e envie as três cores decodificadas de sRGB para linear.
  4. Ligue o blending com gl.enable(gl.BLEND) e gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) e desenhe.

O shader só desenha; os cliques continuam sendo trabalho da página. Teste o ponteiro contra a mesma caixa arredondada em JavaScript (ou ponha um botão HTML transparente sobre o canvas) e devolva o estado de hover ou de pressionado como um uniform.

Monte no Godot

O shader exportado supõe o HDR 2D desligado (o padrão). Com ele ligado, faça u_toLinear e u_toSrgb devolverem a entrada sem mudança. Para um botão de menu comum, o StyleBoxFlat do próprio Godot (raio de canto, borda, sombra) faz o mesmo sem shader; use este quando a forma precisar se animar ou se transformar.

O código copiado é um shader canvas_item completo. Os canvas items do Godot misturam com alfa por padrão, então a sombra cai sobre o que estiver atrás do nó.

  1. Adicione um ColorRect (ou qualquer Control) a uma cena de interface. Para o tutorial, defina o preset de âncoras como Full Rect, para a proporção dele ser a da viewport.
  2. Defina o Material dele como um novo ShaderMaterial com um novo Shader e cole o código.
  3. Ajuste a meia largura, a meia altura, o raio do canto, a espessura da borda, o tamanho da sombra e as cores em Shader Parameters. O botão se atualiza no editor.
  4. Para torná-lo clicável, faça do ColorRect filho de um Button com todos os estilos em StyleBoxEmpty. No retângulo, ponha o Mouse Filter em Ignore e ligue Visibility › Show Behind Parent, para ele ser desenhado por baixo do texto do botão, e não por cima. O Godot cuida da entrada, do foco e do texto; o shader só desenha por trás deles. Não ponha o material no próprio Button: o texto dele também passaria pelo shader.

Num VisualShader, cada distância é um nó Expression com a função correspondente, e a composição usa nós comuns: Max para a borda, Min para o ícone, SmoothStep para a sombra, um DerivativeFunc em Sum (fwidth) para as bordas e Mix para empilhar as cores.

Ainda não há tutorial de Unreal Engine 5 para este card.

Ainda não há tutorial de Unity para este card.

Ainda não há tutorial de Blender para este card.

Código

rounded-button.fragGLSL
#version 300 es// 2D SDF composition → Rounded UI button, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_width; // Half width: 0.75 (0.1 to 1.4)uniform float p_height; // Half height: 0.25 (0.05 to 0.8)uniform float p_radius; // Corner radius: 0.12 (0.0 to 0.8)uniform float p_borderWidth; // Border width: 0.02 (0.0 to 0.15)uniform float p_shadowSize; // Shadow size: 0.1 (0.01 to 0.4)uniform vec3 p_faceColor; // Face color: vec3(0.27, 0.36, 0.95), sRGB; upload it decoded to linearuniform vec3 p_borderColor; // Border color: vec3(0.55, 0.62, 1.0), sRGB; upload it decoded to linearuniform vec3 p_iconColor; // Icon color: vec3(1.0, 1.0, 1.0), sRGB; upload it decoded to linearin vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// A box shrunk by r, then grown back by r: growing a distance field rounds every convex cornerfloat sdRoundBox(vec2 p, vec2 halfSize, float r) {	vec2 q = abs(p) - halfSize + r;	return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;}float sdSegment(vec2 p, vec2 a, vec2 b) {	vec2 pa = p - a;	vec2 ba = b - a;	float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);	return length(pa - ba * h);}// fwidth() is how much d changes across one pixel, so the edge is one pixel soft at any size or zoomfloat fill(float d) {	float w = fwidth(d);	return 1.0 - smoothstep(-0.5 * w, 0.5 * w, d);}vec4 effect(Inputs i) {	vec2 p = (i.uv * 2.0 - 1.0) * vec2(i.resolution.x / i.resolution.y, 1.0);	vec2 halfSize = vec2(p_width, p_height);	float r = min(p_radius, min(halfSize.x, halfSize.y));	float body = sdRoundBox(p, halfSize, r);	// Subtraction: the body without a copy of itself shrunk by the border width, which leaves an inner stroke	float border = max(body, -(body + p_borderWidth));	// Union: two capsules meeting at the tip make a chevron	float iconSize = 0.4 * p_height;	vec2 tip = vec2(0.4 * iconSize, 0.0);	float upper = sdSegment(p, vec2(-0.3 * iconSize, iconSize), tip);	float lower = sdSegment(p, vec2(-0.3 * iconSize, -iconSize), tip);	float icon = min(upper, lower) - 0.12 * iconSize;	// The same box, lower and blurred by widening the smoothstep: a drop shadow costs one more distance	float shadowDistance = sdRoundBox(p - vec2(0.0, -0.4 * p_shadowSize), halfSize, r);	float shadow = 0.45 * (1.0 - smoothstep(-p_shadowSize, p_shadowSize, shadowDistance));	vec3 framed = mix(p_faceColor, p_borderColor, fill(border));	vec3 face = mix(framed, p_iconColor, fill(icon));	float coverage = fill(body);	// The black shadow under the face, as straight alpha: it adds opacity, and only the face adds color	float alpha = coverage + shadow * (1.0 - coverage);	vec3 color = face * coverage / max(alpha, 0.0001);	return vec4(color, alpha);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), frag_out.a);}
rounded-button.gdshaderShader do Godot
// 2D SDF composition → Rounded UI button, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded, blend_mix;uniform float width : hint_range(0.1, 1.4, 0.01) = 0.75;uniform float height : hint_range(0.05, 0.8, 0.01) = 0.25;uniform float radius : hint_range(0.0, 0.8, 0.01) = 0.12;uniform float borderWidth : hint_range(0.0, 0.15, 0.005) = 0.02;uniform float shadowSize : hint_range(0.01, 0.4, 0.01) = 0.1;uniform vec3 faceColor : source_color = vec3(0.27, 0.36, 0.95);uniform vec3 borderColor : source_color = vec3(0.55, 0.62, 1.0);uniform vec3 iconColor : source_color = vec3(1.0, 1.0, 1.0);struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) {	return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// A box shrunk by r, then grown back by r: growing a distance field rounds every convex cornerfloat sdRoundBox(vec2 p, vec2 halfSize, float r) {	vec2 q = abs(p) - halfSize + r;	return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;}float sdSegment(vec2 p, vec2 a, vec2 b) {	vec2 pa = p - a;	vec2 ba = b - a;	float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);	return length(pa - ba * h);}// fwidth() is how much d changes across one pixel, so the edge is one pixel soft at any size or zoomfloat fill(float d) {	float w = fwidth(d);	return 1.0 - smoothstep(-0.5 * w, 0.5 * w, d);}vec4 effect(Inputs i) {	vec2 p = (i.uv * 2.0 - 1.0) * vec2(i.resolution.x / i.resolution.y, 1.0);	vec2 halfSize = vec2(width, height);	float r = min(radius, min(halfSize.x, halfSize.y));	float body = sdRoundBox(p, halfSize, r);	// Subtraction: the body without a copy of itself shrunk by the border width, which leaves an inner stroke	float border = max(body, -(body + borderWidth));	// Union: two capsules meeting at the tip make a chevron	float iconSize = 0.4 * height;	vec2 tip = vec2(0.4 * iconSize, 0.0);	float upper = sdSegment(p, vec2(-0.3 * iconSize, iconSize), tip);	float lower = sdSegment(p, vec2(-0.3 * iconSize, -iconSize), tip);	float icon = min(upper, lower) - 0.12 * iconSize;	// The same box, lower and blurred by widening the smoothstep: a drop shadow costs one more distance	float shadowDistance = sdRoundBox(p - vec2(0.0, -0.4 * shadowSize), halfSize, r);	float shadow = 0.45 * (1.0 - smoothstep(-shadowSize, shadowSize, shadowDistance));	vec3 framed = mix(u_toLinear(faceColor), u_toLinear(borderColor), fill(border));	vec3 face = mix(framed, u_toLinear(iconColor), fill(icon));	float coverage = fill(body);	// The black shadow under the face, as straight alpha: it adds opacity, and only the face adds color	float alpha = coverage + shadow * (1.0 - coverage);	vec3 color = face * coverage / max(alpha, 0.0001);	return vec4(color, alpha);}void fragment() {	Inputs i;	// UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(UV.x, 1.0 - UV.y);	// A flat quad facing the viewer, the same as the site's 2D preview	i.position = vec3(i.uv * 2.0 - 1.0, 0.0);	i.normal = vec3(0.0, 0.0, 1.0);	i.view = vec3(0.0, 0.0, 1.0);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = 1.0 / SCREEN_PIXEL_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), frag_out.a);}

Ainda não há código de Unreal Engine 5 para este card.

Ainda não há código de Unity para este card.

Ainda não há código de Blender para este card.

Leitura complementar