← Volver al listado de tecnologías

Efectos Visuales y Partículas

Por: Artiko
defoldvfxparticulasshadersefectosvisual

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

  1. Assets → New → Particle FXexplosion.particlefx
  2. 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:

Ejercicio 2: Magic Spells

Implementa diferentes hechizos mágicos:

Ejercicio 3: Environmental Effects

Agrega efectos ambientales:

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.