Skip to content
Hanchin Hsieh edited this page Jul 4, 2026 · 8 revisions

Welcome to the soluna wiki! google translate this page into English

soluna 是一个基于 sokol 的 2D 游戏引擎。它以 Lua 为编程语言,整合了 ltask 作为多线程框架。sokol + lua 是其名字的由来。

构建

soluna 目前支持 Windows/macOS/Linux/Web ,可以使用 luamake 构建各个平台的版本。在 Windows 平台,也可以使用 GNU Make 构建:默认使用 mingw 环境,可以用 make CC=cl 切换为 msvc 环境。

在构建完毕后,引擎所有相关代码和资源都会打包到一个执行文件中,没有额外的数据文件依赖。你也可以直接下载预编译版本

使用

非浏览器环境

执行引擎执行文件可以运行游戏。

游戏由若干 lua 代码文件和相关的资产文件(如数据、图片等)构成。可以是一个本地目录,也可以是一个 zip 包。引擎运行时,默认检查当前目录下是否有 main.zip ,若有则将其作为需要启动的游戏;若没有则将当前目录作为游戏所在地。也可以从命令行传入 zip 包文件名改变需要启动的游戏,或传入一个游戏环境文件指定游戏。

游戏环境

游戏环境指游戏运行的若干配置,其默认值在 src/data/settingdefault.dl 被打包到引擎执行文件中。可以在游戏包中放入一个环境文件 main.game 覆盖这些默认配置。或在命令行中指定游戏环境文件名。

也可以在命令行中通过 key=value 指定需要改写的配置项。

entry

入口文件名是游戏启动时第一个被加载运行的 lua 代码文件。默认为 main.lua ,即引擎默认会在游戏包中查找名为 main.lua 的文件运行它。如果引擎在运行时找不到这个 entry (入口)文件,则会报告 Can't load entry main.lua 并退出。

如果命令行指定 soluna test/window.game 启动,则会以 test/window.game 为环境启动游戏(并将当前目录设定为 test)。因为在 test/window.game 中指定了 entry:window.lua ,所以 test/window.lua 就成为了游戏入口。

也可以通过命令行指定 soluna entry=test/window.lua 也可以以它为入口文件启动游戏。这里的命令行参数 entry=test/window.lua 指将 entry 的默认值 main.lua 修改为 test/window.lua

浏览器环境

soluna.js 是一个 ES module,默认导出 createApp 工厂函数;页面侧需要用 import 或动态 import() 加载它,再显式传入 canvasargumentspreRunlocateFile 等参数。

一个典型流程如下:

  1. 使用 soluna (Windows/macOS/Linux) 进行游戏开发。
  2. 将游戏源码 (lua) 打包成 main.zip
  3. 在仓库根目录构建 wasm runtime:
    • luamake -compiler emcc
  4. 部署页面时,需要提供 soluna.jssoluna.wasmmain.zip
  5. soluna wasm 使用了 pthread,因此页面必须启用跨源隔离(COOP/COEP)。具体要求可见 https://emscripten.org/docs/porting/pthreads.html#pthreads-support 。如果静态托管平台不方便直接配置响应头,也可以像示例站点一样使用 coi-serviceworker.min.js 来补上隔离环境。

示例站点的构建方式

示例站点源码位于 website/ 目录,职责分为两部分:

  1. luamake 负责构建 wasm runtime,本体产物位于 bin/emcc/<mode>/,包括 soluna.jssoluna.wasm,以及可选的 soluna.wasm.map
  2. website/scripts/prepare-runtime.mjs 在 Astro 构建前把这些运行时文件复制到 website/public/runtime/,同时打包 asset/ 生成 asset.zip
  3. GitHub Pages workflow 会先执行 .github/actions/soluna,再把这些路径通过环境变量传给 Astro build,最后部署 website/dist/

如果你只是想运行站点,最直接的入口是:

luamake -compiler emcc
cd website
pnpm install
pnpm run dev

更多细节可以参考仓库里的 website/README.zh-CN.md

最小部署示例

下面是一个最小示例。它会以 ES module 的方式加载 soluna.js,再把 main.zip 写入 memfs 后启动 soluna。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Soluna Game</title>
  <style>
    body { margin: 0; background: #000; }
    canvas { display: block; width: 100vw; height: 100vh; }
  </style>
</head>
<body>
  <canvas id="canvas" oncontextmenu="event.preventDefault()"></canvas>

  <script type="module">
    import createApp from './soluna.js';

    async function fetchBytes(url) {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status} while fetching ${url}`);
      }
      return new Uint8Array(await response.arrayBuffer());
    }

    async function main() {
      if (!window.crossOriginIsolated) {
        throw new Error('Cross-origin isolation is required for pthread-enabled Soluna wasm.');
      }

      const canvas = document.getElementById('canvas');
      const mainZip = await fetchBytes('./main.zip');

      await createApp({
        canvas,
        arguments: ['zipfile=/data/main.zip'],
        locateFile(path) {
          return new URL(path, import.meta.url).toString();
        },
        preRun: [
          function (module) {
            module.FS_createPath('/', 'data', true, true);
            module.FS.writeFile('/data/main.zip', mainZip, { canOwn: true });
          },
        ],
        print(text) {
          console.log(text);
        },
        printErr(text) {
          console.error(text);
        },
        onAbort(reason) {
          console.error('Program aborted:', reason);
        },
      });
    }

    main().catch((error) => {
      console.error(error);
    });
  </script>
</body>
</html>

extlua

extlua 是 soluna 用来加载外部 C 模块的一套机制。通过此机制,可以为 soluna 扩展常规的 Lua C 模块,也可以进一步扩展渲染材质。

典型配置如下:

extlua_entry : extlua_init
extlua_preload : sample

这表示启动时会按 package.cpath 查找名为 sample 的外部模块,并以 extlua_init 作为入口函数加载它。extlua_preload 可以是单个模块名,也可以是模块名列表。

init API

外部模块不应该直接链接 Lua 和 soluna 的源码,而要通过宿主注入的 API 表访问运行时提供的实现。这样可以避免外部模块和宿主之间出现版本错配,例如外部模块自己链接了另一份 Lua,或使用了和当前 soluna runtime 不一致的 ABI。

常规 C 模块通常只需要 Lua API,因此只需要:

luaapi_init(L);

如果外部模块还要扩展渲染材质,则需要额外初始化 material API:

luaapi_init(L);
materialapi_init(L);

外部材质 C 代码通过 extlua/materialapi.h 获取材质扩展接口;sokol 相关资源的创建、pipeline 初始化和 draw 调用由宿主侧 soluna.material.ext 完成。

扩展外部材质

外部材质除了普通 Lua C 模块能力外,还需要访问 soluna 的材质扩展 API。因此 C 模块需要包含 materialapi.h,并在入口函数中初始化 materialapi_init

#include <lua.h>
#include <lauxlib.h>

#include "materialapi.h"

LUA_API void luaapi_init(lua_State *L);
void materialapi_init(lua_State *L);

#if defined(_WIN32)
#define EXTLUA_EXPORT __declspec(dllexport)
#else
#define EXTLUA_EXPORT __attribute__((visibility("default")))
#endif

EXTLUA_EXPORT int
extlua_init(lua_State *L) {
    luaapi_init(L);
    materialapi_init(L);

    luaL_Reg libs[] = {
        { "ext.material.my_material", luaopen_ext_material_my_material },
        { NULL, NULL },
    };
    luaL_newlib(L, libs);
    return 1;
}

编译外部材质模块时,需要把以下文件编译进去:

extlua/extlua.c
extlua/materialapi.c

其中:

  • extlua/extlua.c 提供 Lua API 桥接;
  • extlua/materialapi.c 提供材质扩展 API 桥接;
  • extlua/materialapi.h 是外部材质使用材质扩展 API 时需要包含的头文件。

外部材质 C 模块通常会导出一个 Lua 模块,例如 ext.material.my_material。这个模块一般包含:

  • set_material_id(id):保存 render service 分配给该材质的 id;
  • sprite(...) 或类似函数:调用 material_push 生成可加入 batch 的绘制数据;
  • instance_size:单个 instance 的字节大小;
  • hooks:通过 material_push_hooks 暴露给 Lua 的 hook 表。

hooks 中支持以下字段:

  • shader:必需,返回 sg_shader_desc
  • submit:必需,把 material_item 转换成 instance 数据;
  • pipeline:可选,补充 sg_pipeline_desc,例如 vertex layout;
  • uniform:可选,为每个 item 填充 per-item uniform;使用它时 Lua 侧必须提供 item_uniform

materialapi.h 中和外部材质最相关的接口包括:

#define MATERIAL_DATA_SIZE 12

typedef const char *material_error;

struct material_item {
    float x;
    float y;
    int transform_index;
    int sprite;
    int texture;
    struct material_sprite_rect rect;
    unsigned char data[MATERIAL_DATA_SIZE];
};

struct material_push_item {
    float x;
    float y;
    float scale;
    float rotation;
    int sprite;
    const void *data;
};

typedef const sg_shader_desc *(*material_shader_desc_func)(sg_backend backend);
typedef void (*material_pipeline_desc_func)(sg_pipeline_desc *desc);
typedef material_error (*material_submit_one_func)(const struct material_item *item, void *instance);
typedef material_error (*material_uniform_one_func)(const struct material_item *item, void *uniform);

void material_push_hooks(lua_State *L, const struct material_hook *hooks);
int material_push(lua_State *L, int material_id, const struct material_push_item *item);

业务 Lua 代码通常不直接构造 draw primitive,而是调用外部材质 C 模块提供的函数。这个函数内部调用 material_push,返回可加入 batch 的二进制绘制数据。例如:

local mat = require "ext.material.my_material"

batch:add(mat.sprite(sprite_id, {
    color = 0xffffffff,
}), x, y)

外部材质还需要在游戏配置中声明动态库和材质注册脚本:

extlua_entry : extlua_init
extlua_preload : sample
extlua_material : my_material
extlua_material_path : extlua/material/?.lua

其中:

  • extlua_preload 负责预加载包含材质 C 接口的外部模块;
  • extlua_material 指定 render service 要加载的外部材质名;
  • extlua_material_path 指定外部材质注册脚本的查找路径。

extlua_material 可以是单个材质名,也可以是材质名列表。内置材质 id 从 0 开始分配;外部材质 id 从 256 开始分配,并通过注册脚本里的 ctx.id 传入。

注册脚本会在 render service 中加载和执行。脚本会收到一个 ctx 参数,常用字段包括:

  • ctx.id:外部材质 id;
  • ctx.state:render service 的共享渲染状态;
  • ctx.arg:render 初始化参数,其中 ctx.arg.bank_ptr 是 sprite bank 指针;
  • ctx.settings:游戏配置;
  • ctx.rendersoluna.render 模块;
  • ctx.tmp_buffer:render 临时 buffer。

注册脚本负责创建 buffer、bindings 等渲染资源,然后返回 soluna.material.ext.new(...) 创建出的材质对象。这个对象提供 render service 需要的 resetsubmitdraw 方法。

一个最小注册脚本通常类似这样:

local render = require "soluna.render"
local matext = require "soluna.material.ext"
local mat = require "ext.material.my_material"

local ctx = ...
local state = ctx.state

mat.set_material_id(ctx.id)

local inst_buffer = render.buffer {
    type = "vertex",
    usage = "stream",
    label = "extlua-my-material-instance",
    size = mat.instance_size * ctx.settings.draw_instance,
}

local bindings = render.bindings()
bindings:vbuffer(0, inst_buffer)
bindings:view(0, state.views.storage)
bindings:sampler(0, state.default_sampler)

return matext.new {
    id = ctx.id,
    instance_size = mat.instance_size,
    inst_buffer = inst_buffer,
    bindings = bindings,
    uniform = state.uniform,
    sr_buffer = state.srbuffer_mem,
    sprite_bank = ctx.arg.bank_ptr,
    texture_views = state.views,
    texture_view_slot = 1,
    hooks = mat.hooks,
    label = "extlua-my-material-pipeline",
}

matext.new 的常用参数如下:

  • id:材质 id,通常传 ctx.id
  • instance_size:单个 instance 的大小;
  • inst_buffer:用于提交 instance 数据的 vertex buffer;
  • bindings:绘制时使用的 bindings;
  • hooks:C 模块暴露的 hook 表;
  • sr_buffer:scale/rotation buffer;
  • uniform:整批绘制共享的 uniform;
  • sprite_bank:如果材质使用 sprite,则需要传入;
  • texture_viewstexture_view_slot:如果材质按 sprite 所在 texture 切换 texture view,则需要传入;
  • item_uniform:如果 hooks 中提供了 uniform,则必须传入;
  • vertex_count:可选,默认 4;
  • base_element:可选,默认 0;
  • uniform_slot:可选,默认 0;
  • item_uniform_slot:可选,默认 1;
  • blend:可选,默认开启 alpha blend;
  • label:可选,用于 pipeline label。

完整示例可以参考:

  • test/extlua.game
  • test/extlua.lua
  • test/extlua/material/perspective_quad.lua
  • extlua/extlua_sample.c
  • clibs/sample/make.lua