Skip to content

第 16 章:音效、特效与场景渲染 ​

把玩法判定与音效、特效、光照和后处理表现分离,并管理复用生命周期。

开始前与本章路线 ​

  • 准备:先在编辑器配置可复用 HitEffect;音频输出可用。光照、天空实验使用独立地图。
  • 安装范围:每块独立;命中反馈函数需要显式调用。循环声音、天空和后处理在结束时停止或销毁自己创建的对象。
  • 本次要看到:分别记录可听声音、可见特效和视觉变化;只看到日志不能勾选可听/可见。

停止试玩后再换入口;这些章节是能力实验,第 22 章才提供统一的六文件项目。每次只改变一个条件,并保留本次输出用于对照。

你会学到什么 ​

  • 什么时候用 SoundService,什么时候用 SoundUnit。
  • 如何用 EffectUnit 播放一次性特效,并避免特效残留。
  • 如何把命中反馈拆成“判定”和“表现”。
  • 如何用 LightingService、Sky 和后处理做场景氛围。
  • 如何避免表现层代码污染玩法权威逻辑。

表现层不是玩法判定 ​

音效、特效、相机、光照都属于表现层。它们让玩家“看见”和“听见”结果,但不应该替代服务端玩法判定。

text
server 判定命中 / 得分 / 受伤
→ server 更新权威状态
→ server 或 RemoteEvent 通知需要看到表现的客户端
→ client / server 播放音效、特效、相机震动、UI 反馈

如果所有玩家都必须看到同一个世界特效,可以由 server 创建。如果只有本地玩家需要看到,例如命中准星提示、受击红屏、UI 音效,优先放在 client。

SoundService:短音效和全局音量 ​

SoundService 适合播放短促的 2D/3D 音效,例如点击声、命中声、爆炸声,也适合管理音组音量。

Play2D:不带空间位置的声音 ​

示例 1|独立实验;整体替换 client/main.lua。 本次演示端:client。

lua
-- @runtime client
local SoundService = game:GetService("SoundService")
if not SoundService then return end

-- Play2D(soundId, volume, speed, playerOrCampRoleId, campRoleId, duration)
-- false 表示不限定具体玩家或阵营;duration > 0 时便于自动结束。
SoundService:Play2D("official://audio/10772", 80, 1, false, false, 2)

2D 音效适合 UI 点击、倒计时提示、任务完成提示。它不随世界距离衰减。

Play3D:在世界位置播放声音 ​

示例 2|独立实验;整体替换 client/main.lua。 本次演示端:client。

lua
-- @runtime client
local SoundService = game:GetService("SoundService")
if not SoundService then return end

local hitPos = Vector3(0, 5, 0)

-- Play3D(soundId, position, duration, volume, speed, player)
-- false 表示不限定具体玩家。
SoundService:Play3D("official://audio/10772", hitPos, 2, 90, 1, false)

3D 音效适合爆炸、机关、环境物体等“声音从某个位置传来”的场景。

音组音量 ​

示例 3|独立实验;整体替换 client/main.lua。 本次演示端:client。

lua
-- @runtime client
local SoundService = game:GetService("SoundService")
if not SoundService then return end

SoundService:SetGroupVolume("BGM", 50)
SoundService:SetGroupVolume("SFX", 85)

local sfxVolume = SoundService:GetGroupVolume("SFX")
print("[SE Lua Guide] 当前 SFX 音量:", sfxVolume)

SoundService.GroupVolumeChanged:Connect(function(name, volume)
    print("[SE Lua Guide] 音组音量变化:", name, volume)
end)

GroupVolumeChanged 是本地事件,适合更新设置界面上的音量显示。

SoundUnit:持续播放和可控声音实例 ​

如果声音需要循环、暂停、停止、改变衰减距离,使用 SoundUnit 会比一次性 Play2D/Play3D 更清楚。

示例 4|独立实验;整体替换 client/main.lua。 本次演示端:client。

lua
-- @runtime client
local World = game:GetService("World")
if not World then return end

local bgm = World:CreateUnit("SoundUnit", {
    SoundId = "official://audio/10772",
    Looped = true,
    Volume = 45,
})

if not bgm or not bgm:IsA("SoundUnit") then
    print("[SE Lua Guide][WARN] SoundUnit 创建失败。")
    return
end

bgm:Play()

-- 需要切换关卡、暂停游戏或关闭音乐时:
-- bgm:Stop()
-- bgm:Destroy()

常见选择:

需求推荐
UI 点击声SoundService:Play2D
命中点爆炸声SoundService:Play3D
背景音乐SoundUnit
持续环境声SoundUnit
玩家设置音量SoundService:SetGroupVolume

EffectUnit:一次性视觉特效 ​

EffectUnit 用来播放视觉特效。本例将它直接放在 World 下,命名为 HitEffect,通过公开且可保存的 EffectId 配置特效资源;不设置主动绑定。Lua 在运行期控制位置与可见性。Visible、Duration 和绑定数据不能在编辑器中保存为初始状态;需要隐藏时在运行初始化阶段调用 SetVisible(false)。

示例 5|独立实验;整体替换 client/main.lua。 本次演示端:client。

lua
-- @runtime client
local World = game:GetService("World")
local Task = game:GetService("Task")
if not World or not Task then return end

local effect = World:FindFirstChild("HitEffect", true)
if effect and effect:IsA("EffectUnit") then
    local duration = 1.2
    effect:SetPosition(Vector3(0, 5, 0))
    effect:SetDuration(0) -- 长期复用,不自动销毁;duration 仅用于下面的延时隐藏。
    effect:SetVisible(true)
    effect:Restart()

    Task:Delay(duration, function()
        if effect and effect.Parent then
            effect:SetVisible(false)
        end
    end)
end

关键规则:

规则原因
特效资源在编辑器配置资源选择与预览在编辑器完成,Lua 使用本页列出的控制接口。
复用实例使用 SetDuration(0)正数会到期自动销毁;显示时长由业务计时器控制。
显示与重播分开SetVisible 控制可见性;一次性资源播完后用公开 Restart() 重播。
到时显式隐藏SetDuration 不替代本教程的生命周期清理。
SetColor 传 Color(...)先启用 EnableColor,并选择支持染色的 efx 资源;sfx 会忽略染色。

特效颜色、缩放和绑定 ​

示例 6|独立实验;整体替换 client/main.lua。 本次演示端:client。

lua
-- @runtime client
local World = game:GetService("World")
local Task = game:GetService("Task")
if not World or not Task then return end

local effect = World:FindFirstChild("HitEffect", true)
if effect and effect:IsA("EffectUnit") then
    local duration = 2
    effect:SetPosition(Vector3(0, 4, 0))
    effect:SetDuration(0) -- 长期复用,不自动销毁;duration 仅用于下面的延时隐藏。
    effect:SetScale(Vector3(1.5, 1.5, 1.5))
    effect.EnableColor = true
    effect:SetColor(Color(255, 80, 40, 255), 100, 0.6)
    effect:SetRate(1.2)
    effect:SetVisible(true)
    effect:Restart()

    Task:Delay(duration, function()
        if effect and effect.Parent then
            effect:SetVisible(false)
        end
    end)
end

需要让特效跟随某个单位时,使用公开的 SetBindData(otherUnit, socket, offset, bindType)。其中:

  • otherUnit 必须是 SpaceUnit。
  • socket 是具体模型资产提供的挂点名;当前 Meta 没有承诺所有模型都存在名为 Root 的通用挂点。
  • offset 是位置偏移。
  • bindType 使用 Enums.EffectBindType.POS / POS_ROT / POS_SCALE / ALL。

只有从目标模型的公开资源说明或编辑器配置中确认挂点名后,才能填写并实测这次绑定。绑定特效适合角色光环、技能蓄力、拾取物发光;一次性命中特效通常只需要 SetPosition,不应为了示例猜一个挂点名。

命中反馈封装 ​

下面的函数只负责表现,不负责判定。判定命中、加分、扣血仍应在服务端玩法逻辑中完成。

运行端:server 或 client,取决于这段表现要给谁看。

示例 7|函数片段;在同一入口末尾调用 PlayHitFeedback(Vector3(0, 5, 0)) 后观察。 本次演示端:client。

lua
-- @runtime client
local SoundService = game:GetService("SoundService")
local World = game:GetService("World")
local Task = game:GetService("Task")
if not SoundService or not World or not Task then return end

local hitEffect = World:FindFirstChild("HitEffect", true)
local effectGeneration = 0

local function PlayHitFeedback(position)
    SoundService:Play3D("official://audio/10772", position, 2, 90, 1, false)

    if hitEffect and hitEffect:IsA("EffectUnit") then
        local duration = 1.2
        effectGeneration = effectGeneration + 1
        local generation = effectGeneration

        hitEffect:SetVisible(false)
        hitEffect:SetPosition(position)
        hitEffect:SetDuration(0) -- 保留复用对象;不要把显示时长设成销毁倒计时。
        hitEffect:SetVisible(true)
        hitEffect:Restart()

        Task:Delay(duration, function()
            -- 连续命中时,旧定时器不能关闭较新的特效播放。
            if generation == effectGeneration and hitEffect and hitEffect.Parent then
                hitEffect:SetVisible(false)
            end
        end)
    end

    print("[SE Lua Guide] 命中表现已播放")
end

如果只有命中者本人需要看到更强反馈,可以由 server 发送 RemoteEvent 给该玩家,在 client 本地播放增强版音效、特效、相机震动或 UI 动画。

LightingService:全局光照 ​

LightingService 控制场景环境光和主光。它的颜色字段使用 Color(RGBA 0~255),不是 Color3。

示例 8|独立实验;整体替换 server/main.lua。 本次演示端:server。

lua
-- @runtime server
local LightingService = game:GetService("LightingService")
if not LightingService then return end

LightingService.AmbientColor = Color(40, 50, 80, 255)
LightingService.AmbientIntensity = 0.8

LightingService.Color = Color(180, 210, 255, 255)
LightingService.Intensity = 1.5

-- Orientation 是 Vector3,表示角度制欧拉角,不是 CFrame。
LightingService.Orientation = Vector3(45, -30, 0)
LightingService.ShadowFade = 0.6

常见氛围:

氛围调整方向
白天主光强,环境光偏亮。
夜晚主光弱,环境光偏蓝紫。
危险区域环境光偏红,后处理略提高对比度。
梦幻区域Bloom 强一点,天空模板更鲜艳。

Sky:天空必须挂到 LightingService 下 ​

Sky 是可定制天空单元。新项目优先用 Sky,通过 Enums.SkyTemplate.* 切换模板。它必须挂到 LightingService 下才生效;移除或销毁 Sky 后会恢复默认天空。切换关卡氛围时,要清楚当前是谁创建了天空,避免多个系统互相覆盖。

示例 9|独立实验;整体替换 server/main.lua。 本次演示端:server。

lua
-- @runtime server
local LightingService = game:GetService("LightingService")
if not LightingService then return end

local sky = game:CreateUnit("Sky", {
    Parent = LightingService,
    Template = Enums.SkyTemplate.Night,
    Orientation = Vector3(0, 25, 0),
    YOffset = 0,
})

if sky and sky:IsA("Sky") then
    print("[SE Lua Guide] 夜晚天空已设置:", sky.Template)
else
    print("[SE Lua Guide][WARN] Sky 创建失败。")
end

game:CreateUnit 与 World:CreateUnit 的父节点语义不同:前者不会自动设置 Parent,未显式传入时对象保持“游离”(Detached);后者会把新对象放到 World 下。

后处理:挂到 LightingService 下才生效 ​

Bloom、ColorGrading、DepthOfField、AmbientOcclusion 等后处理都继承自 BasePostEffect,核心开关是 Enabled。通常通过 game:CreateUnit 创建,并挂到 LightingService 下。

示例 10|独立实验;整体替换 client/main.lua。 本次演示端:client。

lua
-- @runtime client
local LightingService = game:GetService("LightingService")
if not LightingService then return end

local bloom = game:CreateUnit("BloomEffect", {
    Parent = LightingService,
    Enabled = true,
    Intensity = 0.35,
    Threshold = 1.4,
    Size = 0.8,
})

local grading = game:CreateUnit("ColorGradingEffect", {
    Parent = LightingService,
    Enabled = true,
    Saturation = 0.9,
    Contrast = 1.08,
    Temperature = -0.1,
})

if not bloom or not grading then
    print("[SE Lua Guide][WARN] 后处理创建不完整,请检查当前渲染配置。")
end

后处理会影响整体画面,属于高影响表现。建议先做轻量调整,再在目标设备上检查性能和可读性。

常见错误 ​

错误:只设置 Duration,不负责隐藏 ​

本章复用的是编辑器中预配置的 EffectUnit。SetDuration 大于 0 会到期自动销毁;小于等于 0 不自动销毁,并取消已有计时器。本例使用 SetDuration(0) 保留对象,再由 Task:Delay 到时隐藏。默认 Duration=-1 本身也不自动销毁,不能仅因没有显式设置 0 就断言实例必然失败。

错误:连续播放时让旧定时器关闭新特效 ​

同一个预配置特效可能在旧 Delay 到期前再次播放。保存 generation,并只允许最新一轮定时器隐藏实例;否则第二次播放会被第一次的清理提前截断。

错误:把 Color3 传给 LightingService ​

LightingService.AmbientColor 和 LightingService.Color 使用 Color(...),RGBA 范围 0~255。

错误:Sky 或后处理没有挂到 LightingService ​

Sky、BloomEffect、ColorGradingEffect 等需要 Parent = LightingService 才能生效。

错误:表现逻辑决定玩法结果 ​

声音和特效可以由 client 播放,但得分、扣血、奖励发放等结果必须由 server 判定。

练习任务 ​

  1. 写一个 PlayHitFeedback(position),同时播放 3D 音效和一次性特效。
  2. 给特效设置颜色、缩放和播放速率。
  3. 用 LightingService 切换一个夜晚氛围。
  4. 创建一个 Sky 并挂到 LightingService 下。
  5. 思考:哪些表现只给本地玩家看,哪些需要所有玩家看到?

练习参考与复核 ​

先完成练习,再展开对照;没有运行条件的项目填写“未验证”和缺少的条件。

参考思路与应观察的结果

本地按钮反馈放 client,公共命中事实由 server 决定。连续播放同一特效时检查第二次不会被旧 Delay 提前隐藏。换关卡时回收自己创建的 SoundUnit、Sky 和后处理,并恢复进入前的光照参数。

  • [ ] 我已完成本章选定实验,记录了上面的预期现象。
  • [ ] 我能故意改变一个前置条件,解释失败发生在哪一步,并在恢复后重跑。

本章验收标准 ​

  • [ ] 我知道 SoundService 和 SoundUnit 的适用场景。
  • [ ] 我能用 Play2D / Play3D 播放短音效。
  • [ ] 我能定位编辑器中已配置的 EffectUnit,并用公开方法控制时长和可见性。
  • [ ] 我知道 LightingService 使用 Color(...) 和 Vector3(...)。
  • [ ] 我知道 Sky 和后处理要挂到 LightingService 下。
  • [ ] 我能把玩法判定和表现反馈拆开。

本章产物 ​

  • 一个 PlayHitFeedback(position) 封装,至少包含 3D 音效和一次性特效。
  • 一张表现层截图或运行效果记录,确认特效、音效、光照或天空实际可见/可听。
  • 一份表现归属说明,标出哪些反馈只给本地玩家,哪些需要所有玩家看到。

本章 API 对照 ​

下一章预告 ​

表现层学完后,下一章进入资产加载:如何优先用 preset 创建完整模型,再进阶到手动创建单个 Unit。