11 Oct 2025
虽然我最近给 soluna 贡献了不少提交,但我实际上还不懂得如何使用它。
所以我简单写了一个快速入门指南,总结我自己基于 deepfuture 项目学习到的用法。
注意: soluna 目前还处于快速迭代阶段, APIs 随时可能发生重大变化,本文内容可能很快就会过时。
本文截止 commit a673013
等真正熟悉后,我会考虑同步到 soluna 的官方文档中,并维护更新。
local soluna = require("soluna")
local matquad = require("soluna.material.quad")
local mattext = require("soluna.material.text")
local font = require("soluna.font")
local sysfont = require("soluna.font.system")
local math = math
-- soluna 引擎传入的参数, 这是一个 table
-- 包括 width, height, 和 batch 字段
-- 以及其他外部传给 soluna 的字段
args = ...
local FONT_FAMILY = "Wenquanyi Micro Hei" -- 请替换为本机存在的字体
local PANEL_W, PANEL_H = 320, 160
-- 设置窗口标题
soluna.set_window_title("Soluna Quick Start")
-- 获取批渲染 API
local batch = args.batch
-- 创建字体
font.import(assert(sysfont.ttfdata(FONT_FAMILY)))
local font_id = font.name("")
local font_ctx = font.cobj()
-- 创建文字材质
local text_block = mattext.block(font_ctx, font_id, 28, 0xff202020, "CT")
-- 创建提示文字材质
local tip_block = mattext.block(font_ctx, font_id, 20, 0xff405060, "CT")
-- 创建文字对象
local hello_label = text_block("你好,Soluna!", PANEL_W, 64)
-- 创建提示对象
local hint_label = tip_block("把鼠标移到面板上试试", PANEL_W, 32)
-- 创建悬停提示对象
local hover_label = tip_block("鼠标在这里!", PANEL_W, 32)
-- 创建面板和旋转小块材质
local panel_bg = matquad.quad(PANEL_W, PANEL_H, 0xfff2f6ff)
local panel_hover = matquad.quad(PANEL_W, PANEL_H, 0x8033ff66)
local spinner_quad = matquad.quad(48, 48, 0xff3366ff)
-- 状态
local state = { hover = false, t = 0 }
-- 计算面板位置
local panel_x = (args.width - PANEL_W) * 0.5
local panel_y = (args.height - PANEL_H) * 0.5
-- 计算面板中心位置
local panel_cx = panel_x + PANEL_W * 0.5
local panel_cy = panel_y + PANEL_H * 0.5
-- 回调函数, 由引擎调用
local callback = {}
-- 引擎每帧调用
function callback.frame(count)
state.t = state.t + 1
-- pulse 表示缩放系数
-- math.sin 的参数单位是弧度, 0.05 约等于 2π/125
-- 0.05 的周期约为 125 帧, 约 2 秒
-- pulse 在 [0.75, 0.95] 之间变化
local pulse = 0.85 + math.sin(state.t * 0.05) * 0.1
-- 将面板平移并添加到屏幕中央
batch:add(panel_bg, panel_x, panel_y)
-- 如果悬停则叠加悬停背景
if state.hover then
batch:add(panel_hover, panel_x, panel_y)
end
-- 添加文字和提示到面板上, 位置相对于面板左上角
batch:add(hello_label, panel_x, panel_y + 32)
-- 根据悬停状态选择提示文字
batch:add(state.hover and hover_label or hint_label, panel_x, panel_y + 96)
-- 下面的代码展示 layer 的嵌套用法
-- 先平移到面板中心
batch:layer(panel_cx, panel_cy) -- translate
do -- do-end 只是为了在视觉上区分层次, 没有实际作用
batch:layer(pulse, state.t * 0.03, 0, 0) -- scale + rotate
do
-- 最后缩放并添加旋转小块
-- 因为 spinner_quad 的尺寸是 48x48, 所以这里平移 -24, -24 让它中心对齐
batch:add(spinner_quad, -24, -24)
end
-- 弹出旋转层
batch:layer()
end
-- 弹出平移层
batch:layer()
end
-- 鼠标移动时调用
function callback.mouse_move(mx, my)
-- 计算鼠标在面板内的局部坐标
batch:layer(panel_x, panel_y)
do
-- point 会把屏幕坐标逆变换成局部坐标
-- 这里的 mx, my 是屏幕坐标
-- 返回的 lx, ly 是面板内的局部坐标
local lx, ly = batch:point(mx, my)
-- 判断鼠标是否在面板内
state.hover = lx >= 0 and lx <= PANEL_W and ly >= 0 and ly <= PANEL_H
end
-- 弹出平移层
batch:layer()
end
callback.window_resize = function() end
callback.mouse_button = function() end
callback.mouse_scroll = function() end
callback.key = function() end
return callback
./soluna entry=quickstart.lua。
• 目前 soluna 可直接使用的材质模块只有四种:
soluna.load_sprites
载入 sprites 资源, 然后直接使用 batch:add(sprite_id, x, y)
载入指定的 sprite id 。框架会把同一纹理的实例自动批处理,适合普通贴图渲染。soluna.material.quad
: matquad.quad(w, h, color)
返回一段可以 batch:add
的 userdata,用来画 UI 面板、遮罩等。Alpha 通道可控,支持在 batch:layer
变换下缩放、旋转。soluna.material.text
: mattext.block(...)
/mattext.char(...)
生成排版后的文本或字符,常见用法是 mattext.block(font.cobj(), font_id, size, color, align)
得到闭包再渲染整段文字。soluna.material.mask
: mask.mask(sprite_id, color)
会把给定精灵按指定颜色(含透明度)渲染,常用于高亮、灰度等效果。和 matquad
一样返回可直接 batch:add
的 userdata。• mattext.block(font_mgr, font_id [, font_size [, color [, align_string]]]) -> draw_fn, cursor_fn
font.cobj()
返回的指针(light userdata),用于访问字体管理器。font.name(...)
或其它接口取得的字体编号(整数)。返回值:
draw_fn(text, width, height) -> userdata
将字符串排版到给定宽高区域,生成可直接 batch:add
的材质对象。cursor_fn(text, cursor_index, width, height) -> x, y, w, h, next_index, descent
计算插入光标位置(用于文本编辑),同时返回下一个合法的字符索引和当前行的 descent;若无需光标,可忽略此函数。batch:add(obj [, x [, y]])
batch:layer(...)
(入栈;不带参数表示出栈)batch:layer()
: 弹出一层(必须与之前的 push 成对)。batch:layer(rot)
: 仅附加旋转(弧度),围绕当前原点。batch:layer(x, y)
: 平移。batch:layer(scale, x, y)
: 缩放 + 平移。batch:layer(scale, rot, x, y)
: 缩放 + 旋转 + 平移。
调用顺序即执行顺序;多次 layer 可嵌套,最终作用于之后的每个 batch:add。batch:point(mx, my)
: 把屏幕坐标逆变换成当前层的局部坐标,适合命中测试。soluna 还提供了一个基于 yoga 的 layout 模块,这意味着我们可以用来排版。
local soluna = require("soluna")
local layout = require("soluna.layout")
local datalist = require("soluna.datalist")
local matquad = require("soluna.material.quad")
local mattext = require("soluna.material.text")
local font = require("soluna.font")
local sysfont = require("soluna.font.system")
local app = require("soluna.app")
local utf8 = utf8
local table = table
local args = ...
local batch = args.batch
soluna.set_window_title("Soluna Layout Todo Demo")
font.import(assert(sysfont.ttfdata("Wenquanyi Micro Hei")))
local font_id = font.name("")
local font_ctx = font.cobj()
local text_cache = {}
local function text_factory(size, color, align)
size = size or 16
color = color or 0xff000000
align = align or "LT"
local key = table.concat({ size, color, align }, ":")
local fn = text_cache[key]
if not fn then
fn = mattext.block(font_ctx, font_id, size, color, align)
text_cache[key] = fn
end
return fn
end
local todos = {
{ text = "了解 Yoga 布局 API", done = false },
{ text = "搭建 Todo List UI", done = true },
{ text = "整合 batch 渲染", done = false },
{ text = "准备虚拟滚动列表", done = false },
}
local layout_def = [[
id : app
direction : column
padding : 24
gap : 16
background : 0xfff5f7fb
header :
id : header
height : 64
direction : row
alignItems : center
gap : 12
badge :
id : header_badge
width : 36
height : 36
background : 0xff4c8bf5
title :
id : header_title
text : "待办清单"
size : 28
color : 0xff263238
spacer :
flex : 1
counter :
id : header_counter
text : "共 0 项 · 已完成 0"
size : 16
color : 0xff607d8b
list :
id : list_panel
flex : 1
direction : column
gap : 10
children : todo_slots
footer :
id : footer
height : 28
text : "空格:新增待办 · Enter:确认 · Esc:取消 · Backspace:删除字符"
size : 14
color : 0xff90a4ae
]]
local function flatten(tbl)
local list = {}
local n = 1
for k, v in pairs(tbl) do
list[n] = k
list[n + 1] = v
n = n + 2
end
return list
end
local SLOT_COUNT <const> = 10
local editing_index
local editing_text = ""
local function build_children(tag)
if tag ~= "todo_slots" then
return {}
end
local nodes = {}
for i = 1, SLOT_COUNT do
nodes[#nodes + 1] = ("slot_%d"):format(i)
nodes[#nodes + 1] = flatten({
id = "todo_item_" .. i,
direction = "row",
alignItems = "center",
padding = "12 16",
gap = 12,
display = "none",
background = 0xffffffff,
checkbox = flatten({
id = "todo_check_" .. i,
width = 20,
height = 20,
background = 0xffcfd8dc,
}),
label = flatten({
id = "todo_label_" .. i,
flex = 1,
text = "",
size = 18,
color = 0xff37474f,
}),
status = flatten({
id = "todo_status_" .. i,
text = "",
size = 14,
color = 0xffef6c00,
}),
})
end
return nodes
end
local doc = layout.load(datalist.parse_list(layout_def), build_children)
local root = doc.app
local function apply_todo_styles()
local done = 0
for i = 1, SLOT_COUNT do
local todo = todos[i]
local elem = doc["todo_item_" .. i]
local item = elem:attribs()
local check = doc["todo_check_" .. i]:attribs()
local label = doc["todo_label_" .. i]:attribs()
local status = doc["todo_status_" .. i]:attribs()
if todo then
if i > SLOT_COUNT then
break
end
local is_editing = (editing_index == i)
local is_done = todo.done
local text_value = is_editing and editing_text or todo.text
elem:update({ display = "flex" })
item.display = "flex"
item.background = is_editing and 0xffe3f2fd or (is_done and 0xffe8f5e9 or 0xffffffff)
check.background = is_done and 0xff4caf50 or 0xffcfd8dc
label.text = text_value ~= "" and text_value or (is_editing and "(请输入内容)" or "")
label.color = is_editing and 0xff1a73e8 or (is_done and 0xff78909c or 0xff37474f)
if is_editing then
status.text = "输入中"
status.color = 0xff1a73e8
else
status.text = is_done and "完成" or "待办"
status.color = is_done and 0xff66bb6a or 0xffef6c00
end
if is_done then
done = done + 1
end
else
elem:update({ display = "none" })
item.display = "none"
item.background = 0xffffffff
check.background = 0xffcfd8dc
label.text = ""
label.color = 0xff37474f
status.text = ""
status.color = 0xffef6c00
end
end
local header_counter = doc.header_counter:attribs()
header_counter.text = string.format("共 %d 项 · 已完成 %d", #todos, done)
end
local draw_commands = {}
local function rebuild_layout()
apply_todo_styles()
root.width = args.width
root.height = args.height
local nodes = layout.calc(doc)
if not editing_index then
app.set_ime_rect(nil)
end
draw_commands = {}
for _, obj in ipairs(nodes) do
if obj.display == "none" then
goto continue
end
if obj.background then
draw_commands[#draw_commands + 1] = {
data = matquad.quad(obj.w, obj.h, obj.background),
x = obj.x,
y = obj.y,
}
end
if obj.text and obj.text ~= "" then
local factory = text_factory(obj.size, obj.color, obj.text_align)
draw_commands[#draw_commands + 1] = {
data = factory(obj.text, obj.w, obj.h),
x = obj.x,
y = obj.y,
}
end
::continue::
end
if editing_index then
local label = doc["todo_label_" .. editing_index] and doc["todo_label_" .. editing_index]:attribs()
if label and label.x and label.y and label.w and label.h then
app.set_ime_rect(label.x, label.y, label.w, label.h)
else
app.set_ime_rect(nil)
end
end
end
local function start_edit()
if editing_index or #todos >= SLOT_COUNT then
return
end
local new_index = #todos + 1
todos[new_index] = { text = "", done = false }
editing_index = new_index
editing_text = ""
end
local function finish_edit()
if not editing_index then
return
end
local idx = editing_index
if editing_text == "" then
table.remove(todos, idx)
else
todos[idx].text = editing_text
end
editing_index = nil
editing_text = ""
end
local function cancel_edit()
if not editing_index then
return
end
local idx = editing_index
table.remove(todos, idx)
editing_index = nil
editing_text = ""
end
local function backspace_edit()
if not editing_index then
return
end
local len = #editing_text
if len == 0 then
return
end
local offset = utf8.offset(editing_text, -1)
if offset then
editing_text = editing_text:sub(1, offset - 1)
else
editing_text = ""
end
end
local function append_char(codepoint)
if not editing_index or codepoint == 0 then
return
end
if codepoint < 32 then
return
end
local ch = utf8.char(codepoint)
if not ch then
return
end
editing_text = editing_text .. ch
end
rebuild_layout()
local callback = {}
function callback.frame()
rebuild_layout()
for _, cmd in ipairs(draw_commands) do
batch:add(cmd.data, cmd.x, cmd.y)
end
end
function callback.window_resize(w, h)
args.width = w
args.height = h
end
function callback.key(code, state)
if state ~= 0 then
return
end
if code == 32 then -- Space
start_edit()
elseif code == 257 or code == 335 then -- Enter / keypad Enter
finish_edit()
elseif code == 256 then -- Esc
cancel_edit()
elseif code == 259 then -- Backspace
backspace_edit()
end
end
function callback.char(codepoint)
append_char(codepoint)
end
function callback.quit()
batch:release()
end
callback.mouse_move = function() end
callback.mouse_button = function() end
callback.mouse_scroll = function() end
return callback