exp_smooth()

animações inquebráveis

workshop bitcake 2025

1/30

tldr

float exp_smooth(float speed, float dt) { return 1.0f - expf(-speed * dt); }

// todo frame:
curr = lerp(curr, target, exp_smooth(speed, dt));
2/30

movimentação crua

3/30

bora adicionar o clássico tween por coroutine

Vector2 pos;
float duration = 0.2f;

void Update() {
	if (Input.GetKey(KeyCode.Left)) StartCoroutine(MoveTo(pos + Vector2.left * 100.0f));
	// etc..
}

IEnumerator MoveTo(Vector2 to) {
	Vector2 from = pos;
	for (float t = 0.0f; t < duration; t += Time.deltaTime) {
		float blend = EaseInoutQuad(t / duration);
		pos = Vector2.Lerp(from, to, blend);
		yield return null;
	}
	pos = to;
}
4/30

Um código bem simples que provavelmente já foi escrito várias vezes. É o que está por trás de uma lib de tween.

movimentação tween coroutine

5/30

consertar o tween por coroutine

Vector2 pos;
float duration = 0.2f;
Coroutine currCoroutine = null; // <--

void Update() {
	if (Input.GetKey(KeyCode.Left)) BeginMoveTo(pos + Vector2.left * 100.0f);
	// etc..
}

void BeginMoveTo(Vector2 to) { // <--
	if (currCoroutine != null) {
		StopCoroutine(currCoroutine);
	}
	currCoroutine = StartCoroutine(MoveTo(to));
}

IEnumerator MoveTo(Vector2 to) {
	// ...
}
6/30

Tentativa de consertar lembrando de cancelar qualquer coroutine que ainda esteja animando.

movimentação tween (fix??)

7/30

Não é suficiente pra garantir que a animação sempre para no grid. E, mesmo que garantisse isso, ainda tem o desafio de manter a suavidade/continuidade da movimentação.

movimentação exp_smooth

8/30

Não importa o que faça, a animação sempre se adapta e termina no grid.

movimentação exp_smooth (js)

// every frame
if (0) {}
else if (ctx.key == "ArrowLeft")  { target_x -= 100; }
else if (ctx.key == "ArrowRight") { target_x += 100; }
else if (ctx.key == "ArrowUp")    { target_y -= 100; }
else if (ctx.key == "ArrowDown")  { target_y += 100; }

const speed = ...;
x = lerp(x, target_x, exp_smooth(speed, ctx.dt));
y = lerp(y, target_y, exp_smooth(speed, ctx.dt));
9/30

movimentação exp_smooth (c#)

Vector2 pos;
Vector2 target;
float speed;

void Update() {
	if (Input.GetKey(KeyCode.Left)) target += Vector2.left * 100.0f;
	if (Input.GetKey(KeyCode.Right)) target += Vector2.right * 100.0f;
	if (Input.GetKey(KeyCode.Up)) target += Vector2.up * 100.0f;
	if (Input.GetKey(KeyCode.Down)) target += Vector2.down * 100.0f;

	float blend = ExpSmooth(speed, Time.deltaTime);
	pos = Vector2.Lerp(pos, target, blend);
}
10/30

E possui tamanho de implementação consideravelmente menor como extra.

ui fade

11/30

Outro exemplo clássico que muitos já tiveram que implementar: fade de tela de ui.

Quem nunca já teve problema com uma tela que foi "logicamente fechada" porém ainda está visível?

Isso quase sempre é problema de tween/animação cancelada assim como nos exemplos de movimentação acima.

ui fade

let menu_open = false;

// every frame
const target_menu_alpha = menu_open ? 1.0 : 0.0;
const speed = ...;
menu_canvas_group.alpha = lerp(
	menu_canvas_group.alpha,
	target_menu_alpha,
	exp_smooth(speed, ctx.dt)
);
12/30

Uma implementação robusta pode ser bem simples!

ui anim

13/30

Um outro exemplo só que de algo que, a princípio, parece complicado de implementar.

Como animações com exp_smooth() sempre se adaptam pro alvo desejado, a gente pode mexer no estado inicial e deixar o sistema naturalmente animar pra posição final.

Com isso, gerar esses movimentos secundários proceduralmente é extremamente fácil!

Nesse caso específico, nós só adicionamos um offsetY dos rects ao instanciá-los. A posição final é "calculada pelo layout" e só na hora de desenhar adicionamos o offsetY. O exp_smooth() apenas se encarrega de animar o offsetY pra zero.

ui anim

const rects = [...];
const y_offsets = [];

// every frame
if (menu_just_opened) {
	for (const i in rects) {
		y_offsets.push(rand(20, 60));
	}
}

let speed = slider.valueAsNumber;
speed = menu_open ? speed : speed * 2;
const target_menu_alpha = menu_open ? 0.8 : 0.0;
const target_rect_alpha = menu_open ? 1.0 : 0.0;
menu_alpha = lerp(menu_alpha, target_menu_alpha, exp_smooth(speed, ctx.dt));
rects_alpha = lerp(rects_alpha, target_rect_alpha, exp_smooth(speed, ctx.dt));

for (const i in rects) {
	y_offsets[i] = lerp(y_offsets[i], 0.0, exp_smooth(speed / 1.5, ctx.dt));
}
14/30

hierarquia smooth

15/30

Um outro exemplo em que é possível adicionar movimentação secundaria com facilidade.

Cada rect sabe calcular sua posição a partir da posição de seu pai. Como a ordem de resolução é dos filhos pros pais, basta nós adicionarmos um exp_smooth() pra fazer os rects animarem em direção à posição desejada.

É basicamente como se implementa jiggle bones.

hierarquia smooth

function update_node_pos(node, tx, ty, speed, dt) {
	node.tx = tx;
	node.ty = ty;

	node.x = lerp(node.x, node.tx, exp_smooth(speed, dt));
	node.y = lerp(node.y, node.ty, exp_smooth(speed, dt));

	let x = node.x - node.total_width / 2;
	let y = node.y + node.h + NODE_GAP;
	for (const child of node.children) {
		const half_total_width = child.total_width / 2;
		x += half_total_width;
		update_node_pos(child, x, y, speed, dt);
		x += half_total_width + NODE_GAP;
	}
}
16/30

o segredo

17/30

Em todos os exemplos acima, a todo frame o código consegue calular qual o estado final desejado.

Uma vez que sabemos onde estamos e pra onde queremos ir, podemos aplicar esse tipo de animação.

inquebrável

o sistema sempre faz progresso em direção ao 'target'

18/30

ui fade (novamente)

// estado canônico ("source of truth")
let menu_open = false;

///////////////////
// every frame

// estado *transiente* derivado de `menu_open`!!
const target_menu_alpha = menu_open ? 1.0 : 0.0;

// apenas config
const speed = ...;

// estado *persistente* derivado de `menu_open`!!
// SEMPRE faz progresso em direção ao target (que também depende de `menu_open`)
menu_canvas_group.alpha = lerp(
	menu_canvas_group.alpha,
	target_menu_alpha,
	exp_smooth(speed, ctx.dt)
);
19/30

Revisitando o exemplo de fade de tela de ui.

Agora podemos reparar que `menu_open` é a informação que dita todo o resto.

A partir de `menu_open`, definimos qual o `target_menu_alpha` que, por sua vez, torna possível animar o `menu_canvas_group.alpha`.

Uma série de dependencias de informações que são todas 100% derivadas de `menu_open`.

Dessa forma, *não importa* como mude `menu_open` ao longo dos frames. O visual eventualmente estabilizará de acordo com o estado lógico do sistema. (ui visível quando tela está aberta e invisível caso contrário)

além de animações

se prestar atenção, é uma técnica que é extremamente aplicável e efetiva em problemas além de animação!

20/30

Na verdade, exp_smooth() é só a função que permite realizar o blend das animações corretamente de acordo com o deltatime.

A real técnica que possui todas as propriedades desejadas vistas até agora é "immediate mode api".

ex: lerp de paths

21/30

É possível combinar resultados de animações que já usam exp_smooth().

Nesse exemplo, cada curva é animada com exp_smooth(). E o *blend* entre as curvas também é animado com exp_smooth().

ex: lerp de paths

let path_blend = 0.0;
let path_pos_blend = 0.0;
let target_path_blend = 0.0;
let target_path_pos_blend = 1.0;

// every frame
if (0) {}
else if (ctx.key == "ArrowLeft")  { target_path_blend = 0.0; }
else if (ctx.key == "ArrowRight") { target_path_blend = 1.0; }
else if (ctx.key == "ArrowUp")    { target_path_pos_blend = 0.0; }
else if (ctx.key == "ArrowDown")  { target_path_pos_blend = 1.0; }

const speed = ...;
path_blend =     lerp(path_blend,     target_path_blend,     exp_smooth(speed * 2.0, ctx.dt));
path_pos_blend = lerp(path_pos_blend, target_path_pos_blend, exp_smooth(speed, ctx.dt));

const [p0x, p0y] = hermite_lerp(...path0, path_pos_blend);
const [p1x, p1y] = hermite_lerp(...path1, path_pos_blend);

const x = lerp(p0x, p1x, path_blend);
const y = lerp(p0y, p1y, path_blend);
22/30

ex: lerp de camera

23/30

ex: imgui

// a existência do botão é estado derivado do próprio código
// => se esse código não executa, botão não existe!
if (GUI.Button("Click Me!"))
{
	// qualquer coisa dentro do `if` TAMBÉM é diretamente derivado do botão
	// => código executa apenas quando há interação
	DoTheThing();
}
24/30

Imgui é provavelmente o exemplo mais conhecido de aplicar "immediate mode api".

A ideia aqui é que a construção dos widgets é 100% derivada do código que executa. Essa propriedade garante que a UI *nunca* dessincroniza com a lógica.

ex: rendering

// rendering em geral *pode* seguir esse estilo também
foreach (Bullet bullet in bullets) {
	if (bullet.isAlive && bullet.isVisible) {
		// ao invés de criar algo tipo um componente `Renderer`
		// cujo estado de habilitado/desabilitado precisa ser sincronizado
		// com o estado canônico da bala,

		// a gente apenas condicionalmente (e manualmente) renderiza as balas
		// observando *diretamente* seu estado (isAlive && isVisible)

		graphics.renderBullet(bullet);
	}
}
25/30

Similarmente, rendering em geral também pode ser feito com uma api semelhante à de construção de UI.

Inclusive, a própria Unity possui "immediate mode apis" pra renderizar meshes, etc. Você pode encontrá-las dentro da classe `Graphics` (`Graphics.DrawMeshInstanced(...)`).

ex: shape overlap

// igualmente para deteção de overlap de shape

spellRadius = Mathf.Lerp(spellRadius, 0.0f, ExpSmooth(spellRadiusDecay, dt));
foreach (Enemy enemy in enemies) {
	if (Vector3.Distance(enemy.pos, spellPos) < spellRadius) {
		// ao invés de criar ago tipo um componente `SphereTrigger`
		// cujo estado de 'radius' precisa ser sincronizado com o estado do spell
		// que possui spellRadius dinâmico

		DamageEnemy(enemy, spellDamage);
	}
}
26/30

Assim como não precisamos necessariamente gerenciar componentes de rendering para renderizar coisas, não precisamos necessariamente gerenciar componentes de física para realizar queries espaciais.

Na verdade, você já conhece muito bem o melhor exemplo disso: `Physics.Raycast(...)`.

Esse exemplo, porém, mostra que é possível ir até mais além.

nota sobre callbacks

callbacks são a antítese das técnicas apresentadas.

Afinal, quando armazenados, eles são estado que necessariamente precisam ser sincronizados com o estado canônico do sistema.

.........

Bugs costumam morar justamente em códigos que precisam de sincronização de estado.

27/30

Importante discernir de callbacks efêmeros como os que você passa para funções como `list.Select(x => ...)` já que esses possuem lifetime igual à função chamada.

O problema está em callbacks que vivem ao longo de frames. Esses indicam que há estado implícito armazenado em outro subsistema (um callback/lambda tem referência pro this que por sua vez tem mais estado armazenado).

Eles geralmente são a causa de problemas de sincronização do tipo "era pra algo acontecer antes mas não aconteceu porque naquele caso o callback não foi chamado".

Callbacks removem a linearidade do fluxo de código. Isso força, em cada um deles, uma explosão combinatorial de possíveis ordens de execução entre eles.

basicamente

sempre que possível e todo frame:

28/30

obrigado!

espero que seja útil pra vocês!

float exp_smooth(float speed, float dt) { return 1.0f - expf(-speed * dt); }

// todo frame:
curr = lerp(curr, target, exp_smooth(speed, dt));
29/30

Sinta-se à vontade para tirar dúvidas posteriormente, pfv.

referências

............................................

esse post explora mais a ideia de estados derivados de forma mais ampla (vale a pena ler múltiplas vezes)

............................................

mais específico a respeito de imgui, porém brevemente menciona como esse estilo de api pode ser aplicado em outras áreas como física por exemplo

30/30