Efectos Visuales y Partículas
Efectos Visuales y Partículas
Los efectos visuales son fundamentales para crear juegos que se sientan profesionales y engaging. En esta lección aprenderás a usar el sistema de partículas de Defold, crear shaders customizados, y implementar efectos de post-processing que harán que tus juegos se vean espectaculares.
Sistema de Partículas de Defold
Defold incluye un potente sistema de partículas optimizado para móviles que permite crear efectos complejos sin impactar el rendimiento.
Configuración Básica de Partículas
-- Estructura típica de un efecto de partículas
particle_effect/
├── emitter_config.particlefx -- Configuración principal
├── texture.png -- Textura de las partículas
└── effect_controller.script -- Control programático
Crear Sistema de Partículas Básico
- Assets → New → Particle FX →
explosion.particlefx - Configurar propiedades principales:
-- Configuración en explosion.particlefx
[emitter]
id = "main_emitter"
image = "/effects/textures/spark.png"
max_count = 50
emission_rate = 100
duration = 0.5
[properties]
life_time = 1.0
initial_speed = 200
spread = 60
size_x = 16
size_y = 16
[modifiers]
size_mode = "curve"
alpha_mode = "curve"
color_mode = "gradient"
Proyecto Completo: Sistema de VFX
Vamos a crear un sistema completo de efectos visuales para diferentes situaciones de juego.
Estructura del Proyecto VFX
vfx_system/
├── particles/
│ ├── explosion.particlefx
│ ├── magic_burst.particlefx
│ ├── fire_trail.particlefx
│ └── healing_aura.particlefx
├── shaders/
│ ├── water_shader.fp
│ ├── dissolve_shader.fp
│ └── screen_effects.fp
├── materials/
│ ├── water.material
│ ├── dissolve.material
│ └── screen_distortion.material
├── controllers/
│ ├── vfx_manager.script
│ ├── particle_controller.script
│ └── screen_effects.script
└── textures/
├── particle_atlas.atlas
├── noise_textures/
└── gradient_maps/
Parte 1: Efectos de Partículas Avanzados
VFX Manager
-- controllers/vfx_manager.script
local M = {}
-- Configuraciones predefinidas de efectos
local VFX_CONFIGS = {
explosion = {
particlefx = "/particles/explosion.particlefx",
scale = 1.0,
duration = 1.5,
sound = "/sounds/explosion.ogg"
},
magic_burst = {
particlefx = "/particles/magic_burst.particlefx",
scale = 0.8,
duration = 2.0,
sound = "/sounds/magic.ogg"
},
fire_trail = {
particlefx = "/particles/fire_trail.particlefx",
scale = 0.6,
duration = -1, -- Continuo
sound = nil
},
healing_aura = {
particlefx = "/particles/healing_aura.particlefx",
scale = 1.2,
duration = 3.0,
sound = "/sounds/heal.ogg"
}
}
function init(self)
self.active_effects = {}
self.effect_pools = {}
-- Crear pools de efectos para optimización
create_effect_pools(self)
print("VFX Manager inicializado")
end
function create_effect_pools(self)
for effect_name, config in pairs(VFX_CONFIGS) do
self.effect_pools[effect_name] = {}
-- Pre-crear efectos para pool
for i = 1, 5 do
local effect_id = factory.create("/vfx_factories#" .. effect_name .. "_factory")
particlefx.stop(effect_id)
table.insert(self.effect_pools[effect_name], effect_id)
end
end
end
function play_effect(self, effect_name, position, rotation, scale_override)
local config = VFX_CONFIGS[effect_name]
if not config then
print("Efecto no encontrado:", effect_name)
return nil
end
-- Obtener efecto del pool o crear nuevo
local effect_id = get_effect_from_pool(self, effect_name)
if not effect_id then
effect_id = factory.create("/vfx_factories#" .. effect_name .. "_factory")
end
-- Configurar posición y escala
go.set_position(position, effect_id)
if rotation then
go.set_rotation(rotation, effect_id)
end
local final_scale = scale_override or config.scale
go.set_scale(vmath.vector3(final_scale, final_scale, 1), effect_id)
-- Reproducir efecto
particlefx.play(effect_id)
-- Sonido asociado
if config.sound then
sound.play(config.sound)
end
-- Programar cleanup si tiene duración definida
if config.duration > 0 then
timer.delay(config.duration, false, function()
stop_effect(self, effect_id, effect_name)
end)
end
-- Trackear efecto activo
self.active_effects[effect_id] = {
name = effect_name,
start_time = socket.gettime()
}
return effect_id
end
function get_effect_from_pool(self, effect_name)
local pool = self.effect_pools[effect_name]
if pool and #pool > 0 then
return table.remove(pool)
end
return nil
end
function stop_effect(self, effect_id, effect_name)
if effect_id and go.exists(effect_id) then
particlefx.stop(effect_id)
-- Devolver al pool después de un delay
timer.delay(0.5, false, function()
return_effect_to_pool(self, effect_id, effect_name)
end)
self.active_effects[effect_id] = nil
end
end
function return_effect_to_pool(self, effect_id, effect_name)
if go.exists(effect_id) then
local pool = self.effect_pools[effect_name]
if pool and #pool < 10 then -- Limitar tamaño del pool
go.set_position(vmath.vector3(0, -1000, 0), effect_id) -- Mover fuera de pantalla
table.insert(pool, effect_id)
else
go.delete(effect_id)
end
end
end
-- API pública
function M.explosion(position, scale)
return play_effect(M, "explosion", position, nil, scale)
end
function M.magic_burst(position, rotation)
return play_effect(M, "magic_burst", position, rotation)
end
function M.fire_trail(position)
return play_effect(M, "fire_trail", position)
end
function M.healing_aura(position)
return play_effect(M, "healing_aura", position)
end
function M.stop_all_effects()
for effect_id, data in pairs(M.active_effects) do
stop_effect(M, effect_id, data.name)
end
end
return M
Controlador de Partículas Programático
-- controllers/particle_controller.script
go.property("effect_type", "custom")
go.property("auto_start", false)
function init(self)
self.effect_active = false
self.emitter_url = msg.url(".", "particlefx", "emitter")
if self.auto_start then
start_effect(self)
end
print("Particle Controller inicializado:", self.effect_type)
end
function start_effect(self)
if self.effect_active then return end
self.effect_active = true
particlefx.play(self.emitter_url)
-- Configuraciones específicas por tipo
configure_effect_type(self)
end
function configure_effect_type(self)
if self.effect_type == "fire" then
configure_fire_effect(self)
elseif self.effect_type == "water" then
configure_water_effect(self)
elseif self.effect_type == "magic" then
configure_magic_effect(self)
elseif self.effect_type == "smoke" then
configure_smoke_effect(self)
end
end
function configure_fire_effect(self)
-- Configurar propiedades dinámicamente
particlefx.set_constant(self.emitter_url, "emitter", "tint", vmath.vector4(1, 0.3, 0, 1))
particlefx.set_constant(self.emitter_url, "emitter", "size_x", 24)
particlefx.set_constant(self.emitter_url, "emitter", "size_y", 32)
-- Animar intensidad del fuego
animate_fire_intensity(self)
end
function animate_fire_intensity(self)
-- Variar la emisión para simular llamas vivas
go.animate(".", "tint", go.PLAYBACK_LOOP_PINGPONG,
vmath.vector4(1, 0.6, 0.2, 0.8), go.EASING_INOUTSIN, 0.5)
end
function configure_water_effect(self)
particlefx.set_constant(self.emitter_url, "emitter", "tint", vmath.vector4(0.2, 0.6, 1, 0.7))
-- Simular gravedad del agua
local gravity = vmath.vector3(0, -500, 0)
particlefx.set_constant(self.emitter_url, "emitter", "force", gravity)
end
function configure_magic_effect(self)
particlefx.set_constant(self.emitter_url, "emitter", "tint", vmath.vector4(0.7, 0.2, 1, 0.9))
-- Movimiento circular mágico
animate_magic_swirl(self)
end
function animate_magic_swirl(self)
-- Rotar el emisor para crear efecto de espiral
go.animate(".", "rotation.z", go.PLAYBACK_LOOP_FORWARD,
math.rad(360), go.EASING_LINEAR, 2.0)
end
function configure_smoke_effect(self)
particlefx.set_constant(self.emitter_url, "emitter", "tint", vmath.vector4(0.3, 0.3, 0.3, 0.6))
-- Movimiento ascendente del humo
local upward_force = vmath.vector3(0, 100, 0)
particlefx.set_constant(self.emitter_url, "emitter", "force", upward_force)
end
function stop_effect(self)
if not self.effect_active then return end
self.effect_active = false
particlefx.stop(self.emitter_url)
end
function on_message(self, message_id, message, sender)
if message_id == hash("start_effect") then
start_effect(self)
elseif message_id == hash("stop_effect") then
stop_effect(self)
elseif message_id == hash("set_intensity") then
set_effect_intensity(self, message.intensity or 1.0)
end
end
function set_effect_intensity(self, intensity)
-- Ajustar propiedades basado en intensidad
local scale = intensity
local emission_rate = 50 * intensity
local alpha = math.min(1.0, intensity)
go.set_scale(vmath.vector3(scale, scale, 1))
particlefx.set_constant(self.emitter_url, "emitter", "rate", emission_rate)
particlefx.set_constant(self.emitter_url, "emitter", "tint.w", alpha)
end
Parte 2: Shaders Customizados
Shader de Agua Animada
// shaders/water_shader.fp
varying highp vec2 var_texcoord0;
uniform lowp sampler2D texture_sampler;
uniform lowp vec4 tint;
uniform highp float time;
// Parámetros del agua
const float wave_strength = 0.02;
const float wave_speed = 2.0;
const float wave_frequency = 10.0;
void main()
{
// Coordenadas base
highp vec2 uv = var_texcoord0;
// Calcular ondas
highp float wave1 = sin(uv.x * wave_frequency + time * wave_speed) * wave_strength;
highp float wave2 = cos(uv.y * wave_frequency * 0.7 + time * wave_speed * 1.3) * wave_strength * 0.7;
// Aplicar distorsión
uv.x += wave1;
uv.y += wave2;
// Muestrear textura con UV distorsionada
lowp vec4 color = texture2D(texture_sampler, uv);
// Efecto de reflexión
highp float reflection = sin(uv.y * 20.0 + time * 3.0) * 0.1 + 0.9;
color.rgb *= reflection;
// Aplicar tint y output
gl_FragColor = color * tint;
}
Shader de Disolución
// shaders/dissolve_shader.fp
varying highp vec2 var_texcoord0;
uniform lowp sampler2D texture_sampler;
uniform lowp sampler2D noise_texture;
uniform lowp vec4 tint;
uniform highp float dissolve_amount;
uniform lowp vec4 dissolve_color;
void main()
{
// Textura principal
lowp vec4 main_color = texture2D(texture_sampler, var_texcoord0);
// Textura de ruido para la disolución
lowp float noise = texture2D(noise_texture, var_texcoord0).r;
// Calcular disolución
highp float dissolve_threshold = dissolve_amount;
highp float edge_thickness = 0.1;
// Si el ruido está por debajo del threshold, disolver
if (noise < dissolve_threshold) {
discard;
}
// Crear borde brillante
highp float edge_factor = smoothstep(dissolve_threshold, dissolve_threshold + edge_thickness, noise);
lowp vec3 edge_glow = dissolve_color.rgb * (1.0 - edge_factor);
// Combinar color principal con el brillo del borde
main_color.rgb += edge_glow;
gl_FragColor = main_color * tint;
}
Material de Agua
-- materials/water.material
name: "water"
tags: "water"
vertex_program: "/builtins/materials/sprite.vp"
fragment_program: "/shaders/water_shader.fp"
vertex_constants {
name: "view_proj"
type: CONSTANT_TYPE_VIEWPROJ
}
fragment_constants {
name: "tint"
type: CONSTANT_TYPE_USER
value { x: 0.3, y: 0.6, z: 1.0, w: 0.8 }
}
fragment_constants {
name: "time"
type: CONSTANT_TYPE_USER
value { x: 0.0 }
}
samplers {
name: "texture_sampler"
wrap_u: WRAP_MODE_REPEAT
wrap_v: WRAP_MODE_REPEAT
filter_min: FILTER_MODE_LINEAR
filter_mag: FILTER_MODE_LINEAR
}
Parte 3: Efectos de Post-Processing
Screen Effects Controller
-- controllers/screen_effects.script
function init(self)
self.screen_effects = {
shake = false,
flash = false,
blur = false,
color_grade = false
}
self.shake_intensity = 0
self.shake_duration = 0
self.original_camera_pos = vmath.vector3(0, 0, 0)
print("Screen Effects Controller inicializado")
end
function update(self, dt)
-- Actualizar screen shake
if self.screen_effects.shake then
update_screen_shake(self, dt)
end
-- Actualizar otros efectos...
update_screen_flash(self, dt)
update_color_grading(self, dt)
end
function update_screen_shake(self, dt)
if self.shake_duration > 0 then
self.shake_duration = self.shake_duration - dt
-- Calcular offset de shake
local shake_x = (math.random() - 0.5) * 2 * self.shake_intensity
local shake_y = (math.random() - 0.5) * 2 * self.shake_intensity
local camera_pos = self.original_camera_pos + vmath.vector3(shake_x, shake_y, 0)
go.set_position(camera_pos, "/camera")
-- Reducir intensidad gradualmente
self.shake_intensity = self.shake_intensity * 0.95
else
-- Terminar shake
self.screen_effects.shake = false
go.set_position(self.original_camera_pos, "/camera")
end
end
function update_screen_flash(self, dt)
if self.screen_effects.flash then
-- Implementar flash effect usando render script
msg.post("@render:", "flash_effect", {intensity = self.flash_intensity})
end
end
function update_color_grading(self, dt)
if self.screen_effects.color_grade then
-- Aplicar color grading
msg.post("@render:", "color_grade", {
saturation = self.color_saturation,
brightness = self.color_brightness,
contrast = self.color_contrast
})
end
end
function on_message(self, message_id, message, sender)
if message_id == hash("screen_shake") then
start_screen_shake(self, message.intensity or 10, message.duration or 0.5)
elseif message_id == hash("screen_flash") then
start_screen_flash(self, message.color or vmath.vector4(1, 1, 1, 1), message.duration or 0.2)
elseif message_id == hash("color_grade") then
set_color_grading(self, message.saturation, message.brightness, message.contrast)
end
end
function start_screen_shake(self, intensity, duration)
self.screen_effects.shake = true
self.shake_intensity = intensity
self.shake_duration = duration
self.original_camera_pos = go.get_position("/camera")
print("Screen shake iniciado:", intensity, duration)
end
function start_screen_flash(self, color, duration)
self.screen_effects.flash = true
self.flash_color = color
self.flash_duration = duration
self.flash_timer = duration
-- Animar flash
msg.post("@render:", "start_flash", {
color = color,
duration = duration
})
end
function set_color_grading(self, saturation, brightness, contrast)
self.screen_effects.color_grade = true
self.color_saturation = saturation or 1.0
self.color_brightness = brightness or 1.0
self.color_contrast = contrast or 1.0
end
Render Script con Post-Processing
-- render/effects_render.render_script
function init(self)
self.tile_pred = render.predicate({"tile"})
self.gui_pred = render.predicate({"gui"})
self.text_pred = render.predicate({"text"})
self.particle_pred = render.predicate({"particle"})
-- Render targets para post-processing
self.screen_rt = render.render_target({
[render.BUFFER_COLOR_BIT] = {
format = render.FORMAT_RGBA,
width = render.get_width(),
height = render.get_height()
}
})
-- Shaders de post-processing
self.post_process_shader = resource.load("/shaders/post_process.program")
-- Estados de efectos
self.flash_active = false
self.flash_timer = 0
self.flash_color = vmath.vector4(1, 1, 1, 1)
print("Effects Render Script inicializado")
end
function update(self)
-- Actualizar efectos
local dt = socket.gettime() - (self.last_time or 0)
self.last_time = socket.gettime()
if self.flash_active then
self.flash_timer = self.flash_timer - dt
if self.flash_timer <= 0 then
self.flash_active = false
end
end
-- Render to texture
render.set_render_target(self.screen_rt)
render.set_viewport(0, 0, render.get_width(), render.get_height())
render.clear({[render.BUFFER_COLOR_BIT] = vmath.vector4(0.2, 0.2, 0.3, 1)})
-- Render game content
render_game_content(self)
-- Post-processing pass
render.set_render_target(render.RENDER_TARGET_DEFAULT)
apply_post_processing(self)
-- Render GUI on top
render.set_view(vmath.matrix4())
render.set_projection(vmath.matrix4_orthographic(0, render.get_width(), 0, render.get_height(), -1, 1))
render.enable_state(render.STATE_STENCIL_TEST)
render.draw(self.gui_pred)
render.disable_state(render.STATE_STENCIL_TEST)
end
function render_game_content(self)
render.set_viewport(0, 0, render.get_width(), render.get_height())
render.set_view(self.view)
render.set_projection(self.projection)
-- Render sprites y tiles
render.draw(self.tile_pred)
render.draw(self.sprite_pred)
-- Render partículas con blending
render.enable_state(render.STATE_BLEND)
render.draw(self.particle_pred)
render.disable_state(render.STATE_BLEND)
-- Render texto
render.draw(self.text_pred)
end
function apply_post_processing(self)
render.enable_shader(self.post_process_shader)
-- Pasar uniforms para efectos
if self.flash_active then
local flash_intensity = self.flash_timer / self.flash_duration
render.constant_buffer("flash_color", self.flash_color)
render.constant_buffer("flash_intensity", vmath.vector4(flash_intensity, 0, 0, 0))
else
render.constant_buffer("flash_intensity", vmath.vector4(0, 0, 0, 0))
end
-- Pasar tiempo para efectos animados
render.constant_buffer("time", vmath.vector4(self.last_time, 0, 0, 0))
-- Render fullscreen quad con texture del render target
render.set_viewport(0, 0, render.get_width(), render.get_height())
render.set_view(vmath.matrix4())
render.set_projection(vmath.matrix4_orthographic(0, 1, 0, 1, -1, 1))
render.enable_texture(0, self.screen_rt, render.BUFFER_COLOR_BIT)
render.draw_quad()
render.disable_texture(0)
render.disable_shader()
end
function on_message(self, message_id, message, sender)
if message_id == hash("start_flash") then
self.flash_active = true
self.flash_timer = message.duration
self.flash_duration = message.duration
self.flash_color = message.color
end
end
Parte 4: Efectos Específicos de Gameplay
Trail Effect (Estela)
-- effects/trail_effect.script
go.property("trail_length", 10)
go.property("trail_width", 4)
go.property("fade_speed", 2.0)
function init(self)
self.trail_points = {}
self.target_object = nil
print("Trail Effect inicializado")
end
function update(self, dt)
if self.target_object and go.exists(self.target_object) then
update_trail(self, dt)
render_trail(self)
end
end
function update_trail(self, dt)
local target_pos = go.get_position(self.target_object)
-- Agregar nuevo punto si el objeto se movió lo suficiente
if #self.trail_points == 0 or
vmath.length(target_pos - self.trail_points[1].position) > 5 then
table.insert(self.trail_points, 1, {
position = target_pos,
age = 0,
alpha = 1.0
})
-- Limitar longitud del trail
if #self.trail_points > self.trail_length then
table.remove(self.trail_points)
end
end
-- Actualizar edad de puntos
for i = #self.trail_points, 1, -1 do
local point = self.trail_points[i]
point.age = point.age + dt
point.alpha = math.max(0, 1.0 - (point.age * self.fade_speed))
-- Remover puntos muy viejos
if point.alpha <= 0 then
table.remove(self.trail_points, i)
end
end
end
function render_trail(self)
-- Usar line rendering o sprites para crear el trail
for i, point in ipairs(self.trail_points) do
local alpha_factor = point.alpha * (i / #self.trail_points)
local scale_factor = alpha_factor * self.trail_width
-- Crear/actualizar sprite del trail
local trail_sprite_id = "trail_" .. i
msg.post("#" .. trail_sprite_id, "set_position", {position = point.position})
msg.post("#" .. trail_sprite_id, "set_alpha", {alpha = alpha_factor})
msg.post("#" .. trail_sprite_id, "set_scale", {scale = scale_factor})
end
end
function set_target(self, target_id)
self.target_object = target_id
self.trail_points = {} -- Reset trail
end
Damage Number Effect
-- effects/damage_numbers.script
go.property("font_size", 24)
go.property("rise_speed", 100)
go.property("fade_duration", 1.5)
function init(self)
self.damage_value = 0
self.start_time = socket.gettime()
self.start_pos = go.get_position()
-- Configurar texto
setup_damage_text(self)
end
function setup_damage_text(self)
local label_node = gui.get_node("damage_label")
gui.set_text(label_node, tostring(self.damage_value))
-- Color basado en tipo de daño
local color = vmath.vector4(1, 1, 1, 1)
if self.damage_value > 100 then
color = vmath.vector4(1, 0.2, 0.2, 1) -- Rojo para daño alto
elseif self.damage_value > 50 then
color = vmath.vector4(1, 0.8, 0.2, 1) -- Naranja para daño medio
end
gui.set_color(label_node, color)
gui.set_scale(label_node, vmath.vector3(self.font_size/24, self.font_size/24, 1))
end
function update(self, dt)
local elapsed = socket.gettime() - self.start_time
-- Movimiento hacia arriba
local current_pos = self.start_pos + vmath.vector3(0, self.rise_speed * elapsed, 0)
go.set_position(current_pos)
-- Fade out
local alpha = math.max(0, 1.0 - (elapsed / self.fade_duration))
gui.set_color("damage_label", vmath.vector4(1, 1, 1, alpha))
-- Escala que crece y luego se reduce
local scale_factor = 1.0 + math.sin(elapsed * 10) * 0.1
gui.set_scale("damage_label", vmath.vector3(scale_factor, scale_factor, 1))
-- Destruir cuando termine
if elapsed >= self.fade_duration then
go.delete()
end
end
function set_damage_value(self, value)
self.damage_value = value
setup_damage_text(self)
end
Ejercicios Avanzados
Ejercicio 1: Weather System
Crea un sistema de clima dinámico:
- Lluvia: Partículas con física
- Nieve: Movimiento suave y acumulación
- Tormenta: Efectos de iluminación
- Niebla: Shader de post-processing
Ejercicio 2: Magic Spells
Implementa diferentes hechizos mágicos:
- Fireball: Trail de fuego con explosión
- Lightning: Rayos con ramificaciones
- Shield: Barrera con ondas de energía
- Teleport: Efecto de disolución
Ejercicio 3: Environmental Effects
Agrega efectos ambientales:
- Agua interactiva: Ondas al tocar
- Antorchas: Fuego con iluminación dinámica
- Cristales: Refracción y brillos
- Portales: Distorsión del espacio
Próximos Pasos
En la siguiente lección integraremos audio y música dinámica para crear una experiencia audiovisual completa.
⬅️ Anterior: Juego de Plataformas | Siguiente: Audio y Música ➡️
¡Excelente! Ahora dominas el sistema de efectos visuales de Defold y puedes crear VFX profesionales que harán que tus juegos se vean espectaculares.