Conteúdo deste artigo
- Estrutura do Projeto
- Configurando o cenário
- Implementando a trilha do mouse
- Atualizando a trilha a cada quadro
- Propagar ondas a partir da trilha do mouse
- Estendendo o sombreador de vértice
- Usando a altura da onda para misturar cores
- Não se esqueça das sombras!
- Atualizar plano de fundo da cena
- Magia de pós-processamento
- Otimizações prontas para produção
- Solução alternativa para dispositivos móveis
- Personalize sua grade de ondas!
Olá, aqui é Franky Hung. Sou um desenvolvedor front-end criativo de Hong Kong. Neste tutorial, vamos construir um fundo de grade de ondas 3D minimalista, mas hipnotizante!
Um dia, eu estava navegando no Pinterest em busca de inspirações e me deparei com uma animação muito legal mostrando ondas se propagando em uma grade de cubos com sombras realistas. Pensei comigo mesmo: “isso não parece muito difícil de recriar no Three.js, por que não considerar isso um pequeno desafio?”
Antes de mergulhar nos detalhes, aqui está um resumo rápido deste tutorial:
- Estrutura do projeto
- Configurando o cenário
- Implementando a trilha do mouse
- Propague ondas a partir da trilha do mouse
- Magia de pós-processamento
- Otimizações prontas para produção
- Personalize sua grade de ondas!
Vamos começar!
Estrutura do Projeto
root
├── src
| ├── ThreeJS
| | ├── Camera.js
| | ├── Orchestrator.js
| | ├── Renderer.js
| | ├── Stage.js
| | ├── Effects
| | | ├── MouseTrail.js
| | | └── VignetteRGBShiftShader.js
| | └── Utils
| | ├── Debug.js
| | └── Sizes.js
| ├── index.html
| ├── script.js
| └── style.css
├── public
| └── arrow.svg
├── package.json
└── vite.config.js
Vite foi escolhido para agrupamento porque é uma ferramenta de construção moderna amplamente utilizada e de muito desempenho. script.js é nossa entrada que inicializa a cena Three.js, vincula-a ao elemento canvas no index.html e executa outro código interativo. Tudo relacionado ao Three.js está agrupado na pasta ThreeJS.
A forma como estruturo meu código Three.js é fortemente inspirada nos padrões de Bruno Simon em seu curso. Deixe-me explicar brevemente as funções de cada arquivo:
-
Orchestrator.js: Um singleton central que inicializa e coordena a cena, câmera, renderizador, utilitários essenciais e loop de atualização. Esta é a espinha dorsal da nossa cena Three.js. -
Camera.js: Gerencia oPerspectiveCamerae o comportamento de inclinação da câmera com base no mouse. -
Renderer.js: Alças a configuraçãoWebGLRenderere o pipeline de pós-processamento usandoEffectComposer. -
Stage.js: Gerencia todo o conteúdo a ser exibido na cena, incluindo iluminação, a grade de cubo interativo e os controles de gui para todos os parâmetros ajustáveis. -
Utils/Debug.js: Mantém uma instância de GUI singleton e alterna o menu de controles com base em se#debugestá presente no URL. -
Utils/Sizes.js: Monitora as dimensões da janela e a proporção de pixels, emitindo eventos de redimensionamento para a câmera e o renderizador atualizarem de acordo. -
Effects/MouseTrail.js: Rastreia o histórico do mouse para gerar uma textura de trilha dinâmica usada para deslocamento de onda em tempo real. -
Effects/VignetteRGBShiftShader.js: Um shader personalizado que fornece escurecimento periférico sutil e pós-processamento de aberração cromática.
Configurando o cenário
Hora de construir! Não vou colar todas as linhas de código aqui, então consulte o repositório do Github sempre que precisar.
Embora não existam milhares de cubos, reduzir 400 chamadas de draw em um é definitivamente um grande motivo para usar instanciação. Porém, para este tutorial, nosso objetivo é a estética de cubos relativamente maiores, portanto, apenas uma fração dos 400 cubos será renderizada. Como precisamos dos cubos para projetar sombras, MeshPhongMaterial seria suficiente; não precisamos de MeshStandardMaterial aqui, pois não precisamos de iluminação fisicamente precisa para superfícies de cubos simples.
Vamos definir setGrid, updateGrid funções e um setLighting função para suas respectivas funções.
setGrid() {
const count = this.gridSize * this.gridSize; // gridSize: 20
const geometry = new THREE.BoxGeometry(
this.cubeWidth, // width: 0.8
this.cubeHeight, // height: 3
this.cubeWidth,
);
const material = new THREE.MeshPhongMaterial({ color: 0xffffff });
// Per-instance XZ world position passed to the vertex shader
this.offsetAttribute = new THREE.InstancedBufferAttribute(
new Float32Array(count * 2),
2,
);
geometry.setAttribute("aOffset", this.offsetAttribute);
this.instancedMesh.castShadow = true;
this.instancedMesh.receiveShadow = true;
this.scene.add(this.instancedMesh);
// Arrange cube instances into a grid
this.updateGrid();
}
Observe que estamos definindo um atributo de buffer extra, que atua tanto como um ID quanto como a posição 2D de cada instância, que será útil posteriormente. É importante definir castShadow e receiveShadow como true para permitir que os cubos projetem sombras sobre si mesmos.
Em setLighting(), estas linhas são particularmente importantes:
this.directionalLight.castShadow = true; // don't forget this else there'll be no shadows!
this.directionalLight.shadow.mapSize.set(1024, 1024);
this.directionalLight.shadow.radius = 6;
O shadow.mapSize determina a resolução do mapa de sombra renderizado por esta luz. Para otimizar ao máximo sua aplicação, reduza as resoluções ao mínimo até que a qualidade visual comece a cair abaixo do aceitável. Aumente o raio da sombra para obter sombras mais suaves. Finalmente, não se esqueça de ativar o botão principal de sombras do renderizador!
this.instance.shadowMap.enabled = true;
this.instance.shadowMap.type = THREE.PCFShadowMap; // PCFSoftShadowMap was deprecated in Three.js version r182
E pronto! Agora você tem uma grade de cubos brancos sem muito para ver!
Implementando a trilha do mouse
Agora com nossa grade estática instalada, é hora de dar vida a ela! A ideia central é criar ondas que se propagam a partir do caminho do cursor. Para fazer isso, precisamos saber onde está o cursor, não na tela 2D, mas no nosso mundo 3D. Toda essa lógica é tratada em nossa classe MouseTrail.js . Vamos nos aprofundar.
A técnica clássica do Three.js é usar um Raycaster para traduzir a posição do cursor em uma posição mundial 3D, com um ouvinte de evento “pointermove” ativo vinculado à tela. “Disparamos” um raio da câmera através das coordenadas normalizadas do ponteiro e vemos onde ele cruza com um grande plano horizontal invisível que fica em y = 0, o mesmo que a grade.
this.raycaster = new THREE.Raycaster();
// Invisible horizontal plane for pointer → world-space raycasting
this.rayPlane = new THREE.Mesh(
new THREE.PlaneGeometry(bounds, bounds),
new THREE.MeshBasicMaterial({
side: THREE.DoubleSide,
visible: false,
}),
);
this.rayPlane.rotation.x = -Math.PI / 2;
this.rayPlane.updateMatrixWorld(true); // important!
Observe que precisamos chamar rayPlane.updateMatrixWorld(true) após a rotação devido à sua ausência na cena real para exibição; Three.js não atualiza automaticamente sua matriz mundial, então precisamos fazer isso manualmente. Isso nos dá uma superfície plana para fazer interseção consistente, permitindo-nos obter as (x, z) coordenadas do mouse no plano da grade.
Em seguida, precisamos inicializar os dados da trilha e a textura para uso posterior:
// DataTexture (MAX_TRAIL × 1, RGBA float): trail data for the shader
this.trailTexture = new THREE.DataTexture(
this.trailData,
MAX_TRAIL, // 128
1,
THREE.RGBAFormat,
THREE.FloatType,
);
this.trailTexture.needsUpdate = true;
Depois de ter o rayPlane pronto, eis o que fazemos sempre que um evento pointermove é acionado:
- Usando o raycaster para calcular as coordenadas (x, z) do mouse projetado na grade 3D.
- Calcule um
distDeltaque armazena a distância entre a posição atual do cursor e a última. Isto será útil para ajustar as elevações das ondas. - Exclua o ponto mais antigo da matriz de dados da trilha se estivermos armazenando mais do que
MAX_TRAILdefine. - Insira o novo ponto na matriz de dados da trilha, atribuindo também as coordenadas do novo ponto como o último ponto para o próximo ciclo.
Para a matriz de dados de trilha, para cada ponto armazenamos: { x, z, age, distDelta }. Adicionamos um novo ponto apenas quando o cursor se moveu uma distância mínima (trailSpacing), o que evita que a trilha se torne muito densa quando o mouse está quase parado.
Para passar esses dados para a GPU de forma eficiente, vamos serializá-los em um DataTexture. Esta é uma textura especial onde podemos manipular diretamente os dados brutos do pixel. Criaremos uma textura MAX_TRAIL x 1, onde cada “pixel” é um flutuador de 4 componentes (RGBA) que armazena os dados de um ponto de trilha.
- R:
xposição - G:
zposição - B:
agedo ponto em segundos, 0 para o novo ponto - A:
distDelta(distância do ponto anterior), 0 para o primeiro ponto
Atualizando a trilha a cada quadro
Em nosso loop de animação principal, chamamos o método update() de MouseTrail. Este método tem dois trabalhos:
- Age and Prune: itera pela matriz
trail, aumenta oagede cada ponto e remove todos os pontos que excederam sua vida útil. - Fazer upload para GPU: copia os dados da matriz
trailpara nossotrailDataFloat32Arraye, em seguida, sinaliza a textura para uma atualização por configuraçãothis.trailTexture.needsUpdate = true. Isso diz ao Three.js para carregar nossos dados de trilha mais recentes para a placa gráfica.
// Points survive for fadeTime * 4 seconds; at that age the shader
// fade factor exp(-4) ≈ 0.018 makes them visually negligible.
const expiry = this.params.fadeTime * 4;
for (let i = this.trail.length - 1; i >= 0; i--) {
this.trail[i].age += delta;
if (this.trail[i].age > expiry) {
this.trail.splice(i, 1);
}
}
// Upload the latest MAX_TRAIL live points to the texture
const count = Math.min(this.trail.length, MAX_TRAIL);
if (count > 0 || this._uniforms.uTrailCount.value > 0) {
for (let i = 0; i < count; i++) {
const ti = i * 4;
this.trailData[ti] = this.trail[i].x;
this.trailData[ti + 1] = this.trail[i].z;
this.trailData[ti + 2] = this.trail[i].age;
this.trailData[ti + 3] = this.trail[i].distDelta;
}
this.trailTexture.needsUpdate = true;
this._uniforms.uTrailCount.value = count;
}
E é isso! Agora temos um sistema que alimenta continuamente o caminho do mouse até a GPU. Na próxima seção, finalmente modificaremos o sombreador de vértice do cubo para ler essa textura e criar lindas ondas.
Propagar ondas a partir da trilha do mouse
Essa é a parte divertida! Finalmente podemos usar nosso trailTexture para criar ondas! Modificaremos o sombreador de vértice padrão MeshPhongMaterial para deslocar os topos de nossos cubos com base na proximidade da trilha do mouse. Vamos mergulhar novamente em Stage.js.
Estendendo o sombreador de vértice
Three.js fornece um gancho poderoso, onBeforeCompile, que nos permite injetar código GLSL personalizado em seus shaders integrados antes de serem compilados. Isso é perfeito para nossas necessidades, pois podemos adicionar nossa lógica de onda sem escrever um shader totalmente novo do zero.
Dentro de Stage.js, definiremos um método overrideVertexShader que usa o sombreador de vértice padrão e injeta nossa lógica de onda. Este método também será reutilizado posteriormente pelo material de profundidade personalizado. O núcleo dessa lógica acontece dentro de um loop que percorre cada ponto em nosso uTrailTexture.
Vamos detalhar os conceitos-chave aqui:
O loop de textura
// inside overrideVertexShader(vertexShader)
...inserted after #include <begin_vertex>
for ( int i = 0; i < uTrailCount; i++ ) {
// texel layout: (worldX, worldZ, age, distDelta)
vec4 td = texture2D(
uTrailTexture,
vec2( ( float(i) + 0.5 ) / 128.0, 0.5 )
);
...
}
Como nossas dimensões DataTexture são 128×1, só precisamos rolar a coordenada x do índice uv. Também adicionamos 0,5 a ambos os eixos para identificar a posição central de cada texel.
Envelope Gaussiano
float dist = length( worldXZ - td.rg );
float wavefront = uWaveSpeed * td.b;
float relDist = dist - wavefront;
// Gaussian envelope centred on the expanding wavefront
float window = exp( -( relDist * relDist ) / ( uWaveWidth * uWaveWidth ) );
Para cada cubo e cada ponto na trilha do mouse, calculamos a distância do cubo à frente de onda circular em expansão. A variável relDist é fundamental: é a distância do cubo do ponto de trilha menos a distância que a onda já percorreu (wavefront = uWaveSpeed * age), o que nos dá a distância entre a frente de onda e o cubo.
Em seguida, usamos isso relDist em uma função gaussiana. Isso cria uma “protuberância” suave em forma de sino centrada na frente de onda. O uniforme uWaveWidth controla a largura dessa saliência. O resultado é uma ondulação suave e de aparência natural.
Atenuando as ondas
Para tornar o efeito mais físico e menos caótico, aplicamos diversas formas de atenuação para controlar a força da onda (weight):
// Exponential time-fade + distance attenuation
float fade = exp( -td.b / uFadeTime );
float atten = 1.0 / ( 1.0 + dist * 0.1 );
float weight = fade * window * atten * td.a; // td.a is distDelta, used to weaken waves from closely spaced trail points
Time Fade: À medida que um ponto de trilha fica mais antigo (seu age, armazenado em td.b, aumenta), sua influência desaparece exponencialmente. Aumente o uFadeTime uniforme para diminuir a atenuação baseada no tempo.
Atenuação de distância: Quanto mais longe um cubo estiver de um ponto de trilha, mais fraco será o efeito da onda sobre ele. Esta é uma relação inversa simples com a distância.
Atenuação de velocidade: Lembra distDelta de MouseTrail.js? Nós o armazenamos no canal ‘A’ da nossa textura. Nós o usamos aqui para dimensionar o peso da onda. Se o mouse se mover muito devagar, distDelta é pequeno, o que enfraquece a onda. Isso evita que um mouse parado ou em movimento lento crie uma onda enorme e avassaladora.
Frequência de onda e média ponderada para frentes de onda mais sutis
float waveHeight = 0.0;
float totalWeight = 0.0;
for ( int i = 0; i < uTrailCount; i++ ) {
...
waveHeight += weight * cos( uWaveFreq * relDist );
totalWeight += weight;
}
// Weighted average: overlapping waves average rather than stack,
// cancelling chaotic superposition while preserving single-wave peaks.
waveHeight /= max( totalWeight, 1.0 );
Antes de adicionar o peso calculado ao waveHeight de cada cubo, nós o oscilamos com uma função cosseno, controlada pela frequência de onda uniforme. Ao usar uma frequência de onda mais alta, você pode ativar múltiplas frentes de onda em vez de uma única frente de onda ao deslizar o mouse. Mas, por uma questão de estética sutil, vamos manter a frequência da onda em um número menor, ~1.
Se várias ondas de diferentes partes da trilha se sobrepõem, simplesmente adicionar suas alturas pode criar superposições caóticas, pontiagudas e visualmente chocantes. Para evitar isso, calculamos um totalWeight para todas as ondas contribuintes e, em seguida, calculamos uma média ponderada de suas alturas. Isto garante que quando as ondas se sobrepõem, elas se misturam suavemente, criando um efeito mais sutil e agradável, ao mesmo tempo que permite que ondas fortes e únicas atinjam sua altura total.
Efeito de jitter de onda
vec2 jitter = hash2( aOffset ) * uJitter;
vec2 worldXZ = aOffset + jitter;
Se você quiser quebrar os círculos perfeitos das ondas, basta adicionar um pouco de “jitter”. Usamos uma função hash simples baseada na posição mundial estática do cubo (aOffset) para gerar um deslocamento determinístico e pseudo-aleatório para cada cubo. Este jitter é aplicado antes dos cálculos de onda, mudando ligeiramente a posição de cada cubo no campo de onda.
Usando a altura da onda para misturar cores
Este recurso tem sem dúvida o impacto mais profundo na identidade visual da cena, mas é o mais fácil de implementar! Até agora os nossos cubos são muito monótonos; adicionar uma nova cor muda tudo. Ao passar o vHeight calculado do sombreador de vértice para o sombreador de fragmento, podemos usar essa altura para misturar uma cor base e uma cor de destaque.
// inside material.onBeforeCompile
shader.fragmentShader = shader.fragmentShader
.replace(
"#include <common>",
`#include <common>
varying float vHeight;
uniform vec3 uColorBase;
uniform vec3 uColorHigh;
uniform float uMaxHeight;`
)
.replace(
"#include <color_fragment>",
`#include <color_fragment>
float t = clamp( vHeight / uMaxHeight, 0.0, 1.0 );
diffuseColor.rgb = mix( uColorBase, uColorHigh, t );`
);
Esta adição simples realmente faz as ondas “pop” visualmente, com os picos brilhando na cor de destaque e o resto da onda tingido em um gradiente suave em direção à cor base.
Não se esqueça das sombras!
Aqui está um detalhe crucial: para que nossas sombras pareçam corretas, os objetos na passagem do mapa de sombras devem ser deformados exatamente da mesma maneira que os objetos na passagem da câmera principal. Se eles não corresponderem, você obterá artefatos estranhos onde as sombras não se alinham com os objetos que as projetam.
Three.js torna isso mais fácil. InstancedMesh possui uma propriedade customDepthMaterial . Podemos criar um MeshDepthMaterial separado, fornecer a ele exatamente a mesma onBeforeCompile lógica do sombreador de vértice e atribuí-lo. Isso garante que nossas sombras onduladas estejam perfeitamente sincronizadas com nossos cubos ondulados.
// inside setGrid()
const depthMaterial = new THREE.MeshDepthMaterial();
depthMaterial.onBeforeCompile = (shader) => {
// ... copy all uniforms from the main material ...
shader.uniforms.uWaveSpeed = { value: this.params.waveSpeed };
// ... etc ...
// Use the exact same vertex shader override
shader.vertexShader = this.overrideVertexShader(
shader.vertexShader,
);
};
this.instancedMesh.customDepthMaterial = depthMaterial;
Atualizar plano de fundo da cena
Anteriormente, notei flashes escuros repentinos nos espaços entre os cubos enquanto meu mouse se movia pela tela. Para evitar esse problema, devemos garantir que a cor de fundo corresponda ao tom da cor base dos cubos. Quando o usuário alterar o colorBase na GUI, atualizaremos o uniforme do sombreador e a propriedade de plano de fundo da cena.
// inside contructor()
this.scene.background = new THREE.Color(this.params.colorBase).multiplyScalar(0.5); // half of colorBase for a more subtle background
// inside setGUI()
this.gui
.addColor(this.params, "colorBase")
.name("Base Color")
.onChange((v) => {
if (this.shaderRef)
this.shaderRef.uniforms.uColorBase.value.set(v);
this.scene.background = new THREE.Color(v).multiplyScalar(0.5); // half of uColorBase for a more subtle background
});
Magia de pós-processamento
Nossa cena parece boa, mas vamos ser honestos, é um pouco… limpa demais. Para dar uma aparência mais estilizada à cena, precisaremos de pós-processamento. O pós-processamento nos permite aplicar efeitos de tela inteira à cena renderizada, da mesma forma que aplicamos um filtro a uma fotografia. É uma forma poderosa de definir o clima e a estética do seu trabalho.
Para este projeto, criaremos um efeito personalizado que combina uma vinheta sutil (escurecendo os cantos) com uma aberração cromática (dividindo os canais de cores). Isso adicionará à cena uma vibração retro-futurística levemente sonhadora, como se fosse vista através de uma lente de câmera vintage.
O EffectComposer
Primeiro, precisamos configurar um EffectComposer em nosso arquivo Renderer.js . Esta é a ferramenta do Three.js para encadear diferentes passagens de pós-processamento.
A configuração é simples. Criamos um EffectComposer e adicionamos alguns passes essenciais:
-
RenderPass: Este é o passe fundamental. Ele pega nossa cena e câmera e renderiza a cena como está, fornecendo a entrada para os efeitos subsequentes. -
ShaderPass: É aqui que nossa mágica personalizada acontece. Iremos alimentá-lo com nosso próprioVignetteRGBShiftShaderpara manipular a imagem. -
OutputPass: Esta é a etapa final. Ele pega o resultado das passagens anteriores e o renderiza na tela.
Veja como fica em Renderer.js:
// ...
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
import { VignetteRGBShiftShader } from "./Effects/VignetteRGBShiftShader.js";
export default class Renderer {
// ...
setPostProcessing() {
this.composer.addPass(this.renderPass);
this.vignetteRGBShiftPass.uniforms.shiftAmount.value = 0.005; // Adjust the intensity of the RGB shift
this.vignetteRGBShiftPass.uniforms.vignetteRadius.value = 0.3; // Adjust where the effect starts (0.0 to 1.0)
this.vignetteRGBShiftPass.uniforms.vignetteSoftness.value = 0.3; // Adjust the falloff smoothness of the effect
this.composer.addPass(this.vignetteRGBShiftPass);
this.composer.addPass(this.outputPass);
}
// ...
update() {
// render via composer when you have post-processing effects
this.composer.render();
}
}
O personalizado VignetteRGBShiftShader
Agora vamos à receita caseira de pós-processamento! Você pode estar se perguntando se existem efeitos integrados para Vignette e RGBShift, por que criar um shader personalizado? A resposta é simples: o que estou tentando alcançar é um efeito combinado dos dois; usando a vinheta como máscara para aplicar o efeito de aberração cromática. Além disso, para adicionar mais profundidade à cena, dimensionamos a mudança de cor com base na força da máscara de vinheta.
Veja como o fragment shader é implementado em VignetteRGBShiftShader.js:
// ...
void main() {
// 1. Calculate distance from the center of the screen
vec2 center = vec2(0.5);
float dist = distance(vUv, center);
// 2. Create the vignette mask
float vignetteFactor = smoothstep(vignetteRadius, vignetteRadius + vignetteSoftness, dist);
// 3. Scale the shift intensity based on the vignette mask
float currentShift = shiftAmount * vignetteFactor;
// 4. Sample the color channels with the dynamic shift
float r = texture2D(tDiffuse, vUv + vec2(currentShift, currentShift)).r;
float g = texture2D(tDiffuse, vUv).g;
float b = texture2D(tDiffuse, vUv - vec2(currentShift, currentShift)).b;
// 5. Apply a standard darkening vignette overlay
float darken = 1.0 - vignetteFactor * 0.5;
gl_FragColor = vec4(vec3(r, g, b) * darken, 1.0);
}
Antes e Depois:
Otimizações prontas para produção
O código que você vê foi bem otimizado para lançamento em produção, mas acho útil listar o que otimizei antes de lançá-lo online.
- Iterações de loop de textura reduzidas :
MAX_TRAILreduzido de 200 para 128 para largura de textura de dados e vértice sombreador. Isso reduziu o custo de cálculo do shader, diminuindo a pressão da GPU. - Gerenciamento de textura eficiente: Adicionadas verificações condicionais para pular
needsUpdatena textura de dados da trilha quando nenhum ponto está ativo, reduzindo transferências desnecessárias de dados de CPU para GPU. - Otimização de Shader: Simplificou o cálculo da altura da onda no sombreador de vértice, ignorando os vértices inferiores dos cubos, que não são visíveis e não contribuem para o efeito visual final. Isso reduz ainda mais a carga computacional em 50% sem afetar a qualidade visual.
Solução alternativa para dispositivos móveis
Houve um problema significativo em dispositivos móveis; eles não têm o ponteiro do mouse para acionar as ondas! Embora o usuário ainda possa deslizar na tela com os dedos para acionar os mesmos eventos pointermove, isso não é intuitivo, especialmente quando minha página de destino já está ocupando todo o espaço da tela, sem necessidade de rolagem.
Encontrei uma solução simples: começar a adicionar pontos aleatórios à trilha após 3 segundos de inatividade e parar quando o usuário tocar na tela ou mover o mouse. Além disso, a página começa com isPlacingRandomPoint = true, para que algumas ondas comecem a se espalhar mesmo que o usuário móvel ainda não tenha tocado ou passado o dedo na tela.
Personalize sua grade de ondas!
Este é o fim do meu tutorial e obrigado por ler até aqui. Finalmente, quem não gosta de uma cena com parâmetros para brincar e ajustar? Aqui estão alguns exemplos divertidos do que pode ser alcançado para alterar o estilo visual da grade:
Créditos: design de sobreposição de página de destino do modelo gratuito .
