Defold 3D 游戏开发实战

讲清 Defold 做 3D 的真实能力边界:Model 与 Mesh 组件、材质与顶点片元着色器、内置相机组件与 Camera API、自写 Lambert 光照与 Light 组件、3D 物理与射线检测、glTF 模型导入流程、3D 与 2D 混用、Draw Call 与性能取舍,以及适合与不适合做 3D 的判断。

引言

Defold 的核心是 3D 引擎:哪怕只做 2D,所有渲染也是以 3D 完成再正交投影到屏幕上。但「引擎是 3D」不等于「适合做 3D 游戏」——Defold 没有内置地形系统、没有光照烘焙、没有可视化关卡编辑器,3D 能力要自己一层层搭起来。本文先把能力边界说清楚,再逐项讲 Model 与 Mesh 组件、材质与着色器、相机、光照、3D 物理、模型导入、2D 混用与性能取舍。

前置阅读:渲染脚本与渲染管线 、着色器与后处理 。


目录


1. 能力边界与适用判断

1. Defold 有什么、没有什么

有没有
Model 组件,直接吃 glTF 2.0内置地形、植被、水体系统
Mesh 组件,运行时用 buffer 造几何光照烘焙与全局光照
材质系统,自定义顶点/片元着色器可视化 3D 关卡编辑器
Light 组件,光源数据进 shader内置 PBR 管线(只有样例材质)
3D 物理与射线检测3D 寻路网格(NavMesh)
骨骼动画与 GPU 蒙皮内置阴影贴图与后处理堆栈

结论很直接:Defold 提供底层构件,管线要自己组装。渲染顺序、深度测试、相机切换、阴影贴图、后处理,全部写在渲染脚本和着色器里。

2. 适合与不适合

适合:低多边形 / 卡通渲染;俯视角或固定视角的 3D;
      3D 场景 + 2D UI 的混合玩法;小规模关卡
不适合:开放世界与流式加载;大量实时光源与阴影;
        依赖编辑器可视化摆场景;需要 Unity 级插件生态

踩坑:默认渲染脚本是为 2D 定制的,直接用 Model 组件会发现模型不显示或渲染错乱。必须复制默认渲染脚本,加上 model 谓词与深度测试,见第 4 节。


2. Model 与 Mesh 组件

1. Model 组件

Model 组件把一个 glTF 场景渲染出来。关键属性:

属性说明
Scene.gltf / .glb 文件,含几何与形变目标
Mesh可选,只渲染场景中某个具名网格(1.13.2 起)
Skeleton含骨骼的 glTF 文件,要求单一根骨骼
Animations动画集文件
Default Animation自动播放的动画
Material每个网格一份材质
Texture材质对应的贴图

内置材质有四个起点:model.material(静态非实例化)、model_instanced.material(静态实例化)、model_skinned.material(骨骼非实例化)、model_skinned_instanced.material(骨骼实例化)。骨骼与实例化材质用本地顶点空间,GPU 蒙皮必须如此。

运行时用 go.get / go.set 操作的属性有 animation(只读)、cursor、material、playback_rate、textureN(0~15)。动画用 model.play_anim:

local play_properties = { blend_duration = 0.1 }
model.play_anim("#model", "jump", go.PLAYBACK_ONCE_FORWARD, play_properties)

-- 手动推进动画游标
model.play_anim("#model", "run", go.PLAYBACK_NONE)
go.animate("#model", "cursor", go.PLAYBACK_LOOP_PINGPONG, 1, go.EASING_LINEAR, 10)

-- 内置材质的 tint 常量
go.set("#model", "tint", vmath.vector4(1, 0, 0, 1))

2. Mesh 组件

Mesh 组件用于运行时造几何:程序化地形、动态网格、特效条带。属性是 Material、Vertices(buffer 文件)、Primitive Type(Lines / Triangles / Triangle Strip)、Position Stream、Normal Stream、tex0。

-- 用 buffer 写入顶点,再挂到 mesh 上
local vertices = { 0,0,0, 0,1,0, 1,0,0, 1,1,0 }
local buf = buffer.create(#vertices / 3, {
    { name = hash("position"), type = buffer.VALUE_TYPE_FLOAT32, count = 3 }
})
local positions = buffer.get_stream(buf, "position")
for i, v in ipairs(vertices) do positions[i] = v end

-- Mesh 不会被自动视锥剔除,需要手动提供 AABB 元数据
buffer.set_metadata(buf, hash("AABB"), { 0, 0, 0, 1, 1, 1 }, buffer.VALUE_TYPE_FLOAT32)
resource.set_buffer(go.get("#mesh", "vertices"), buf)

踩坑:Mesh 组件的动态特性使引擎无法推断包围盒,因此默认不做视锥剔除。不设 AABB 元数据会让所有 mesh 每帧都被提交,量大时是灾难。


3. 材质与着色器

1. 材质文件结构

一个 .material 把顶点程序(.vp)、片元程序(.fp)和一组参数绑在一起。关键设置是 Vertex Space:

Local Space  顶点数据按对象本地坐标传入,顶点着色器自己乘世界矩阵
             GPU 蒙皮与实例化必须用 Local Space
World Space  引擎先把顶点转到世界空间,便于批处理
             CPU 蒙皮走这条路,但无法实例化

model.material 系列用的是 Local Space,所以自定义材质如果要做 GPU 蒙皮或实例化,也必须设成 Local Space。

2. 顶点与片元程序

一个最小可用的 3D 顶点程序要输出裁剪空间坐标并把世界坐标、法线传给片元:

// model.vp
uniform mediump mat4 view_proj;
attribute mediump vec4 position;
attribute mediump vec3 normal;
varying mediump vec3 var_normal;
varying mediump vec3 var_world;

void main() {
    vec4 world = vec4(position.xyz, 1.0);
    gl_Position = view_proj * world;
    var_world = world.xyz;
    var_normal = normal;
}

片元程序里做最简单的 Lambert:

// model.fp
uniform lowp vec4 tint;
uniform mediump vec3 light_dir;   // 归一化的光方向
varying mediump vec3 var_normal;

void main() {
    float ndl = max(dot(normalize(var_normal), -light_dir), 0.0);
    lowp vec3 ambient = vec3(0.25);
    gl_FragColor = vec4((ambient + ndl) * tint.rgb, tint.a);
}

tint 用 go.set("#model", "tint", vmath.vector4(...)) 在运行时改;light_dir 可以在渲染脚本里用 render.set_shader_constant 或直接按材质常量设置。

踩坑:glTF 里的动画必须是烘焙过的逐骨骼矩阵,并且是线性插值。导出时如果用了曲线插值或分离的位移/旋转/缩放通道,Defold 会播不出正确结果。


4. 3D 相机

1. Camera 组件

Camera 组件挂到 game object 上,用消息激活:

-- 激活 / 释放
msg.post("#camera", "acquire_camera_focus")
msg.post("#camera", "release_camera_focus")

-- 运行时改属性
go.set("#camera", "fov", math.rad(60))
go.set("#camera", "near_z", 0.1)
go.set("#camera", "far_z", 500)
go.set("#camera", "aspect_ratio", 16 / 9)

只读属性是 projection 与 view,用 camera.get_projection() / camera.get_view() 或在渲染脚本里读。

2. 相机 API 与投影模式

-- 投影模式
camera.set_orthographic_mode("#camera", camera.ORTHO_MODE_AUTO_FIT)
camera.set_orthographic_zoom("#camera", 4.0)   -- 像素风放大 4 倍

-- 屏幕与世界互转
local world = camera.screen_to_world(vmath.vector3(x, y, depth))
local screen = camera.world_to_screen(go.get_position())
local near_plane = camera.screen_xy_to_world(x, y)

camera.ORTHO_MODE_FIXED 是固定正交缩放,AUTO_FIT 保证原分辨率内容全部可见(可能露出更多),AUTO_COVER 保证铺满窗口(可能裁掉边缘)。

3. 在渲染脚本里用相机

默认渲染脚本不含 3D 谓词。最小改动是在 init 里建谓词、在 update 里开深度测试:

function init(self)
    self.model_pred = render.predicate({"model"})
end

function update(self)
    render.set_camera({ camera = "camera#camera" })
    render.set_depth_mask(true)
    render.enable_state(graphics.STATE_DEPTH_TEST)
    render.draw(self.model_pred)
    render.set_depth_mask(false)
end

render.set_camera 会自动把该相机的 view 与 projection 应用到后续绘制,比手写 render.set_view / render.set_projection 省事。


5. 光照方案

1. 自写简单 Lambert

轻量 3D 最常见做法是不用引擎光源,直接在片元着色器里写死一个平行光方向,配一个环境光底色。好处是零光源开销、完全可控;坏处是所有物体共用同一套光照,没有局部光。

// 多点光源可以循环一个 uniform 数组
uniform mediump vec3 light_pos[4];
uniform lowp vec3 light_color[4];

for (int i = 0; i < 4; ++i) {
    vec3 L = normalize(light_pos[i] - var_world);
    float ndl = max(dot(normalize(var_normal), L), 0.0);
    accum += ndl * light_color[i];
}

2. Light 组件

Defold 内置四种光源资源:Ambient、Directional、Point、Spot。引擎把光源收集到内置的 light buffer,但不会自动应用到材质——由你的着色器决定怎么用。

Ambient     不受位置/旋转影响,累加成 light_info.xyz,不占 lights[] 数组
Directional 方向由 game object 世界旋转作用于 (0, 0, -1) 得到
Point       位置取 game object 世界坐标,range 为世界单位半径
Spot        锥形,inner_cone_angle / outer_cone_angle 控制内外锥

着色器里声明一个名为 LightBuffer 的 uniform block,引擎会自动绑定光源数据:

#define MAX_LIGHT_COUNT 32
struct Light { vec4 position; vec4 color; };   // xyz 位置 / rgb 颜色 a 强度
uniform LightBuffer {
    vec4 light_info;                  // xyz: 环境光颜色
    Light lights[MAX_LIGHT_COUNT];
};

光源数量上限由项目设置 light.max_count 控制,默认 64;超限会报组件缓冲区错误。环境光虽然不占 lights[] 条目,但仍然算作 Light 组件,占 max_count。

踩坑:light.max_count 是编译期分配的内存,按实际用量调小能省内存;调得太小则在运行时直接报错。调试时先在 Profiler 里看真实峰值再定值。


6. 3D 物理与碰撞

1. 3D 碰撞体

Collision Object 组件配合 3D 形状(Box、Sphere、Capsule)即可参与 3D 物理。物理世界由 physics.* 驱动:

physics.set_gravity(vmath.vector3(0, -9.8, 0))
local g = physics.get_gravity()

-- 运行时改形状(3D 下 box/capsule 改动代价较高)
physics.set_shape("#collisionobject", "box", {
    type = physics.SHAPE_TYPE_BOX,
    dimensions = vmath.vector3(1, 2, 1)
})

-- 分组与掩码
physics.set_group("#collisionobject", "player")
physics.set_maskbit("#collisionobject", "enemy", true)

2. 射线检测

physics.raycast 是最常用的 3D 查询:从相机往屏幕点发射射线、做地面拾取、做视线遮挡判断。

local from = go.get_world_position("#camera")
local to = camera.screen_xy_to_world(action.x, action.y) * 100
local hits = physics.raycast(from, to, { hash("ground"), hash("obstacle") }, {
    all = false   -- 只要最近的一次命中
})
if hits then
    go.set_position(hits.position + vmath.vector3(0, 0.5, 0))
end

返回的 message.physics.ray_cast_response 含 position、normal、fraction、id、group。all = true 时返回数组。physics.raycast_async 用于避免在单帧内做大量查询卡顿。

踩坑:raycast 的 to 必须是世界坐标,不是方向向量。把方向当终点传进去,射线长度就只有 1 单位,什么也打不中。


7. 模型导入与 2D 混用

1. glTF 导入流程

Defold 支持 glTF 2.0,用 .gltf(文本 + 外部 .bin + 贴图)或 .glb(单文件二进制)。把文件拖进 Assets 面板即可。

1. 在 Blender 里 File ▸ Export ▸ glTF 2.0 (.glb/.gltf)
2. 勾选 Selected Objects 只导出选中对象
3. 有动画时开启动画导出,并确保动画已烘焙
4. 贴图单独导入为 .png / .jpg,在材质里指定
5. 新建 Model 文件,Scene 指向 glTF,Skeleton 指向含骨骼的文件
6. 新建 Animation Set,指定给 Animations

自 1.13.0 起,Defold 保留 glTF 中的变换、不再自动居中;1.13.2 起 Model 可以只渲染场景里某个具名网格。如果旧工程重新导入后模型位置变了,回到 Blender 修正变换再导出。

2. 3D 与 2D 混用

混用的关键是渲染顺序与深度测试:先画 3D(开深度测试、清深度),再画 2D UI(关深度测试、正交投影)。

function update(self)
    -- 3D 场景
    render.set_camera({ camera = "world#camera" })
    render.set_depth_mask(true)
    render.enable_state(graphics.STATE_DEPTH_TEST)
    render.draw(self.model_pred)
    render.disable_state(graphics.STATE_DEPTH_TEST)

    -- 2D 覆盖层:正交投影,不写深度
    render.set_projection(vmath.matrix4_orthographic(0, 960, 0, 640, -1, 1))
    render.draw(self.sprite_pred)
end

如果 2D 元素需要夹在 3D 物体之间(比如角色头顶血条),用 render.draw 的第二个参数传入自定义 view / projection 矩阵,或把 2D 元素当作贴在相机前的 billboard。


8. 性能与 Draw Call

1. Draw Call 来源

每个 Model 组件、每个 Mesh 组件、每个材质切换通常都是一次 draw call。降低数量的手段:

- 合并网格:同材质的静态物体在 DCC 里合并成一个 mesh
- 实例化:同网格同材质用 model_instanced.material,引擎自动批量提交
- 减少材质种类:一张图集贴多物体,比多材质省得多
- 关闭不必要的光源与后处理
- Mesh 手动设 AABB,恢复视锥剔除

2. 顶点与纹理预算

- 顶点属性越多,带宽越高:position + normal + uv 是常见下限
- 骨骼蒙皮走 RGBA32F 骨骼矩阵贴图,不支持该格式就无法用本地空间材质
- 静态物体优先用 World Space 材质以便批处理

踩坑:实例化材质必须用 Local Space,且模型必须共用同一网格与材质,否则引擎无法合并提交,实例化反而多一层开销。

3. 什么时候该换引擎

如果你的 3D 需求已经出现「实时光照 + 阴影贴图 + 大场景流式加载 + 可视化关卡编辑」这四项中的三项,Defold 要补的工程量会超过项目本身的价值。反过来,低多边形风格、固定视角、以玩法为核心的 3D 项目,Defold 的轻量反而是优势。


9. 速查表

需求做法备注
显示 3D 模型Model 组件 + model.material默认渲染脚本不渲染 model
运行时造几何Mesh 组件 + resource.set_buffer记得设 AABB 元数据
播动画model.play_anim("#model", "run", go.PLAYBACK_LOOP_FORWARD)动画必须烘焙
改颜色go.set("#model", "tint", vmath.vector4(...))内置材质常量
激活相机msg.post("#camera", "acquire_camera_focus")后激活者生效
渲染脚本画 3Drender.set_camera + render.predicate({"model"})需开 STATE_DEPTH_TEST
自写光照.vp 传法线,.fp 算 max(dot(n, -l), 0)最省的做法
用引擎光源声明 LightBuffer uniform block上限 light.max_count
3D 射线physics.raycast(from, to, groups)to 是世界坐标
导入模型拖入 .gltf / .glb,Scene 指向它贴图单独导入
降 draw call合并网格 + model_instanced.material实例化需 Local Space

一句话记忆:Defold 给你 Model / Mesh / 材质 / Light / 3D 物理这几块积木,但渲染顺序、深度、阴影、相机切换都要在渲染脚本和着色器里自己写;能力边界是「低多边形、固定视角、玩法优先」,越接近写实开放世界就越该考虑换引擎。


相关阅读

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 行为树与游戏 AI 决策
  2. Defold 材质与着色器语言详解
  3. Defold HTML5 导出与 Web 性能优化