Skip to content

feat(extapi): expose font apis#105

Draft
yuchanns wants to merge 2 commits into
cloudwu:masterfrom
yuchanns:feat/font-extapi-opaque
Draft

feat(extapi): expose font apis#105
yuchanns wants to merge 2 commits into
cloudwu:masterfrom
yuchanns:feat/font-extapi-opaque

Conversation

@yuchanns

@yuchanns yuchanns commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

现有的 soluna font 没法满足我编写 app 的需求, 不过对文字的复杂排版、测量和自定义渲染我觉得也不是游戏引擎会太关心的事情, 所以基于 extapi 自己写了个文字扩展材质,但是缺少一些 api, 需要通过 solunaapi 导出, 主要是字体测量和 glyph 查询相关的.

我不确定这样实现边界是否合适.

@yuchanns yuchanns force-pushed the feat/font-extapi-opaque branch from 8d172cb to 81b1eac Compare June 27, 2026 10:47
@cloudwu

cloudwu commented Jun 27, 2026

Copy link
Copy Markdown
Owner

讨论一下这里的 api 设计。如之前所说,extapi 其实是不太好一次设计好的,所以修改在所难免。但最好可以保持通用简洁。

我之前也没有太仔细看 extapi 的接口设计,刚才重新看了一下。

先整理一下 soluna 的材质模块的设计:

soluna 内置的材质数据流,是基于 struct draw_primitive 这个结构的数组。它定义在 batch.h 中:

struct draw_primitive {
	int32_t x;		// sign bit + 23 + 8   fix number
	int32_t y;
	uint32_t sr;	// scale + rot
	int32_t sprite;		// negative : material 
};

默认的材质,每个 sprite 就用一个结构体描述它,这个结构就包含了 sprite id 和它的 SRT 变换。

当我们扩展材质后,如果扩展材质的每个图元需要额外的数据,就采用两个这么长的结构描述一个图元。为了和默认材质区分开,在 sprite 字段填入负数,表示材质编号,但 SRT 依旧保留。接下来的 16 字节(即这个结构长度)则可以放一些扩展材质需要的图元数据。但大多数扩展材质依然公用原有的 sprite bank ,也就是继续去画系统管理的 sprite (它们的像素也储存在系统管理的贴图上),由于扩展材质的 sprite 字段变成了材质号,所以真实的 sprite 就放在了接下来的 16 字节的前 4 字节。即这个结构

struct draw_primitive_external {
	int sprite;
};

真正供扩展材质放额外图元数据的是接下来的 12 字节。

例如,内核实现的 text 材质,就是这样定义扩展结构的:

struct text {
	struct draw_primitive_external header;
	int codepoint;
	uint16_t font;
	uint16_t size;
	uint32_t color;
};

以上 stream 是放在 CPU 内存中的数据,不同材质的数据全部放在统一的 steam 中。

在绘制阶段,一部分的数据会被组织起来提交到 GPU 的 buffer 中(VB),这个过程在引擎中叫做 submit ;另一部分数据会从 stream 里分离出独立的回值流程,被称为 draw 。draw 过程通常是从前面的 stream 中获取图元相关的信息填到 uniform 里,然后调用 sokol 的 sg_draw 完成。

submit 和 draw 都依赖 stream 里的数据。


下面是我推敲的 extapi 的设计思路,并不一定全部正确。

extapi 要做的事情是想把以上,尽可能的封装起来,减少细节的暴露。

soluna_material_push_stream() 用来把数据按内核规定的 stream格式组织起来。它只指定图元的个数,每个图元都会调用一个 soluna_material_stream_write_func callback 。

从前面的描述可知,每个图元必须固定的数据是 SRT / sprite 和额外的最多 12 字节的 payload 。extapi 放弃了 SR 的支持,只允许写入 T ,也就是 x,y 字段,同时允许写入额外的 sprite 和 payload 。注:虽然扩展材质在这里没有支持写入旋转和缩放,但是因为内核支持图层的旋转缩放,所以在最终的 stream 数据中,还是可能有 SR 的。

soluna_material_submit() 封装了把 CPU stream 数据提交到 GPU buffer 的过程。为什么要封装?因为在从 CPU 的 stream buffer 向 GPU 的 buffer 转换时,涉及了某种数据结构的转换,需要一个临时的内存 buffer 中转。在内核原有的数据中,使用了一个固定长度的 tmp_buffer 。这导致了批量 submit 不可以有太多数据,导致其超出这个固定 tmp buffer 的长度。

疑问 :其实在 extlua_sample.c 中,tmp_ptr 是自己实现的,并没有使用内核的固定长度的 tmp buffer 。所以,理论上并不受此限制。

看起来,extapi.c 中的 submit_material_stride() 函数,用一个循环把 submit 这个 callback 的每次调用的图元个数限制在 batch_n 之下,看起来就没有什么意义。而这个函数所作的也仅仅是把 submit 分开。如果不受 batch_n 之限制,其实就没有必要把 soluna_material_submit 作成 callback 驱动的形式了,extapi 会简单得多。


soluna_material_stream_read() 是用来把以上组织在 CPU stream 里的数据再读回来。它在用户写的 submit 过程中调用,读出每个图元的信息,通过 sg_append_buffer() 写入 GPU 的 buffer 。这里,通过结构 struct soluna_material_stream_data 把图元的 x,y,sprite 读回来。扩展材质模块可以在 submit 里调用它组织真正放进 VB 的数据。

疑问 :这个 patch 扩展了 struct soluna_material_stream_data,增加了 axis_x 和 axis_y ,看起来有点草率?原始的内核 stream 里其实是 SRT 和 sprite ,这里转换成另一种形式,感觉不太直观。可能有更好的定义方法?


关于这个 patch ,增加了大量 font 有关的 api 。我的直觉是,把 font 再归到一个独立的模块里,类似之前 sokol lua 的分类感觉更好,这样或许可以和 soluna 这块的变动分离开。然后 font 相关的东西也可以独立探讨设计。

@cloudwu

cloudwu commented Jun 27, 2026

Copy link
Copy Markdown
Owner

抛开目前的 extapi 中的 soluna api 部分。我说说我对扩展材质的想法。首先,这个感觉应该叫 material api (也就是之前版本的 soluna api ),这样就可以和 font api 并列了。

底层因为把所有的材质都放在了一个 stream 里,也就是 struct draw_primitive 数组,扩展材质必须是两个 struct draw_primitive 长度,共 32 字节。其中,留个用户自定义的部分就是 12 字节。前 20 字节都是固定的,也就是 12 字节的 SRT ,4 字节的材质编号,4 字节的 sprite 编号。

如果不使用内部的 sprite 图像数据和贴图管理(如果使用内部 sprite ,底层会利用它管理贴图),那么 sprite 编号固定为 -1 ,就是内部的 text 材质实现。

我觉得不如就固定给扩展材质预留 12 字节的自定义数据,不用额外作变长的处理更简单?这样 payload 相关的 api 设计似乎可以简化不少。

扩展材质模块通常需要自定义什么呢?除了 shader 外,主要是和一致的 stream 交换数据。框架是通过分析 stream 里的材质编号来找到一段相同的数据,交给对应的材质模块 submit 和 draw 的。

材质模块从 lua side 看,主要就是需要提供三个 lua 函数:

第一,用于把用户数据放进底层的 stream 中,通常只有那 12 字节是自定义的。某些材质可能还需要 sprite 。至于图元的 SRT ,用户可以统一用底层的嵌套图层设置。对应内置的 text 材质,就是 block 和 char 两个函数干的事情。

第二,用于把 stream 的数据提交到 VB 。我认为有两种做法,先讨论目前已有的实现,即让用户自己调用 sokol 的 sg_append_buffer() 完成。这样自由度最高。但我觉得其实目前的 api 可以简化。尤其是用户并不需要特别对扩展材质作性能优化,完全可以把合并图元去掉,一次只画一个图元。其实目前的 sample 也是如此。

目前,每个材质的 lua 接口会提供一个名为 submit 的函数。现在 extapi 的思路是:如果用户自定义材质,就让用户自己实现一个 submit() 函数供框架调用。框架调用它时,会传入三个参数,分别是材质对象、stream、图元个数 。这个 submit 需要用户自己用 C 实现,解析 stream ,调用 sg_append_buffer() 。这里 stream 我们不希望公布数据结构的细节,细节本身也很复杂,不适合用户直接取用,所以又提供了 soluna_material_stream_read 解析它。

但我们换个思路,接口可以简单得多。

用户自定义材质并不需要直接实现 submit() ,而实现一个变体 submit_one() ,它只提交一个图元。我们在框架实现一个统一的 submit 用于用户自定义材质,它把 stream 数据转换为方便用户解析的 SRT sprite 以及 12 字节 payload ,取代 stream 直接把数据传给用户。这个函数就好实现的多,也不需要 callback 了。甚至还可以把 sg_append_buffer() 也封装起来。这样用户写的函数要做的仅仅是 CPU 的数据格式转换,转换为 VB 的数据结构而已。

第三,用户根据 stream 数据调用 sg_draw 做真正的绘制。这里框架会调用一个叫 draw() 的函数,传入四个参数:材质对象、stream、图元数量,以及根据 sprite 获取的贴图 id 。

当前的 sample 忽略了 stream 。但实际上大多数材质需要解析 stream 里图元内的数据设置 uniform 。比如 text 材质就是从额外的 payload 里找到颜色设到 uniform 里的。这里解析图元中的 SRT 似乎没什么意义,主要是额外的 payload 才有意义。若想多封装一点,其实和上一条思路一样,未必需要暴露 sokol api ,只需要让用户自定义函数填写单个图元的 uniform 即可。一次 draw 一个图元。除了填写 uniform 外,所有的用户自定义材质的行为都是一致的。如果没有额外的 uniform 需要填写,现在的 sample 就是通用的材质 draw 。


总结:用户自定义材质本质上是写一个 shader 来用自定义方式渲染图元数据。核心在定义 shader ,但 shader 绘制数据的来源分别是 VB 里的 instance 和每次 draw 的 uniform 。我感觉用户自定义材质只需要用户去实现三个 lua 函数,它们都是做数据转换,sokol api 则可以放在之上,由框架实现好。

  1. 把 lua 数据转换为 12 字节的 payload 。
  2. 把 stream 内单个图元的 SRT / sprite / payload 转换为 VB 中的 instance 结构。
  3. 把 stream 内单个图元的 payload 填充到材质对象中的 uniform 结构中,或者可以使用通用 draw 。

@yuchanns

Copy link
Copy Markdown
Contributor Author

其实在 extlua_sample.c 中,tmp_ptr 是自己实现的,并没有使用内核的固定长度的 tmp buffer 。所以,理论上并不受此限制。

tmp_buffer = ctx.tmp_buffer,

m->tmp_ptr = lua_touserdata(L, -1);

目前使用的是内核传过来的 tmp buffer 。不过你说得对,确实可以自己实现. 我只是根据现有的材质的注册流程总结出来这样一个模式直接照搬过来实现。

这个 patch 扩展了 struct soluna_material_stream_data,增加了 axis_x 和 axis_y ,看起来有点草率?原始的内核 stream 里其实是 SRT 和 sprite ,这里转换成另一种形式,感觉不太直观。可能有更好的定义方法?

这里是因为我觉得不应该披露 bit packing 的细节。

这个感觉应该叫 material api

确实这样更合适。全塞在 solunaapi 有点太大了

现在 extapi 的思路是:如果用户自定义材质,就让用户自己实现一个 submit() 函数供框架调用。框架调用它时,会传入三个参数,分别是材质对象、stream、图元个数 。这个 submit 需要用户自己用 C 实现,解析 stream ,调用 sg_append_buffer() 。这里 stream 我们不希望公布数据结构的细节,细节本身也很复杂,不适合用户直接取用,所以又提供了 soluna_material_stream_read 解析它。

确实是这么想的。我不确定应该封装到什么程度合适,会不会导致限制太大,所以就决定自由点自己 submit

基于上述建议, 我重写了一版: #106

@yuchanns yuchanns marked this pull request as draft June 28, 2026 10:03
@cloudwu

cloudwu commented Jun 28, 2026

Copy link
Copy Markdown
Owner

现在 extapi 的思路是:如果用户自定义材质,就让用户自己实现一个 submit() 函数供框架调用。框架调用它时,会传入三个参数,分别是材质对象、stream、图元个数 。这个 submit 需要用户自己用 C 实现,解析 stream ,调用 sg_append_buffer() 。这里 stream 我们不希望公布数据结构的细节,细节本身也很复杂,不适合用户直接取用,所以又提供了 soluna_material_stream_read 解析它。

确实是这么想的。我不确定应该封装到什么程度合适,会不会导致限制太大,所以就决定自由点自己 submit

我觉得用户自定义材质,可以先限制大一点,以更少的 api 和数据结构优先。也不用过于考虑性能。后面遇到新需求时再重构更好。

我甚至觉得,绘制 sprite 只是加一些特效的可能可以归为一类;
而渲染非 sprite 对象,比如文本甚至 3d 模型或者粒子片这些都有可能分开。渲染管线或许会不一样。

目前没那么多需求时,才把两者合并起来处理。有填充 vb 和 uniform 的能力已经能解决大部分自定义需求了。毕竟不是要做 3A 游戏会去抠性能和特别的效果。

现在常见的材质需求是缺少类似 render target 这种设施,但底层框架本来就没有支持。

@yuchanns yuchanns force-pushed the feat/font-extapi-opaque branch 2 times, most recently from 95c0d30 to 0330e60 Compare June 28, 2026 14:06
@yuchanns yuchanns changed the title feat(extapi): expose font queries and stream basis feat(extapi): expose font apis Jun 28, 2026
@cloudwu

cloudwu commented Jun 28, 2026

Copy link
Copy Markdown
Owner

font 现在提供的 api 是在 C side 调用吗?

如果主要是给 lua 用的话,我有一些想法。如果是给 C 用,意见不大。

@yuchanns

Copy link
Copy Markdown
Contributor Author

现在实现上是给 C 用。 不过可以说说看。

@yuchanns

yuchanns commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author
image

写了个 demo 展示下使用方式
https://github.com/yuchanns/soluna_fontapi

@yuchanns yuchanns force-pushed the feat/font-extapi-opaque branch from bb4690d to b67746e Compare June 28, 2026 18:46
@yuchanns yuchanns marked this pull request as ready for review June 28, 2026 18:54
@cloudwu

cloudwu commented Jun 29, 2026

Copy link
Copy Markdown
Owner

目前内核里的 text 部分也有些未实现的功能,只是还没想好对应的应用去做。主要是想增加点击特定文字段的支持,以及显示上加下划线。目前已有的功能很难做到。

我设想的是在输入文本时可以给特定文本加标签,标记上 tag ,有 api 可以查询特定位置的点是否落在某个标签上;同时可以在渲染时给标签传入颜色。这样可以实现鼠标悬停的时候文本变色。

目前的理念是,尽量少在核心部分加特性。但当下文本显示是单向的,无法查询具体文字所在的区域,和鼠标做交互很难。通过现有功能,在 lua 层难以实现点击一段标注的文本。所以我觉得这个是必要的特性。

@cloudwu

cloudwu commented Jun 29, 2026

Copy link
Copy Markdown
Owner

我的想法是,看起来现在增加的 font api 都是查询接口,功用上是类似的。可能合并成一个比较好?

查询时增加一个枚举量,返回到 union 里面,根据枚举量决定返回什么。

我猜想潜在的好处是 ABI 会更稳定一些。因为即使后面增加和修改功能,只用增加新的枚举值,返回对应结构,并不需要改变 ABI 。新版本去查老版本结构,也只是失败而已。

不过,多个接口也有好处,可以在编译链接时做更多校验。ABI 不变似乎也不是特别重要。

@yuchanns

Copy link
Copy Markdown
Contributor Author

我说明一下最早想引入 fontapi 的动机:

一开始我尝试使用现有的 mattext.block 实现以下能力:

local block, cursor = mattext.block(font.cobj(), fontid, size, color, align, line_height)
  • 文本高度测量:对整段文本取末尾 position,调用 cursor(text, len, width, large_height),用返回的 y +
    h 作为实际文本高度。
  local TEXT_MEASURE_HEIGHT = 4096

  local function text_height(text, width)
    text = text or ""

    local position = utf8.len(text) or 0
    local _, y, _, h = cursor(
      text,
      position,
      width,
      TEXT_MEASURE_HEIGHT
    )

    return y + h
  end
  • 鼠标命中字符位置:从 x/y 反推 text position,Lua 层遍历 0..len,对每个 position 调
    cursor(...),找离鼠标点最近的位置。
  local function text_position(text, width, height, x, y)
    text = text or ""

    local len = utf8.len(text) or 0
    local best_position = 0
    local best_score = math.huge

    for position = 0, len do
      local cx, cy, _, ch = cursor(text, position, width, height)

      local dy = 0
      if y < cy then
        dy = cy - y
      elseif y > cy + ch then
        dy = y - cy - ch
      end

      local score = dy * 10000 + math.abs(x - cx)
      if score < best_score then
        best_score = score
        best_position = position
      end
    end

    return best_position
  end
  • 选区绘制:为Lua 层逐字符调用 cursor(position) 和 cursor(position + 1),再拼出
    每个字符或每一段的矩形。
  local function append_selection_rect(rects, width, height, text, position)
    local x1, y1, _, h1 = cursor(text, position, width, height)
    local x2, y2 = cursor(text, position + 1, width, height)

    local w
    if y1 == y2 and x2 >= x1 then
      w = x2 - x1
    else
      w = width - x1
    end

    if w > 0 then
      rects[#rects + 1] = {
        x = x1,
        y = y1,
        w = w,
        h = h1,
      }
    end
  end

  local function selection_rects(text, width, height, start_pos, end_pos)
    local rects = {}

    for position = start_pos, end_pos - 1 do
      append_selection_rect(rects, width, height, text, position)
    end

    return rects
  end
  • 滚动裁剪:为了找到某个 scroll_y 对应的可见起始文本位置,需要遍历 position 并观察 cursor 的 y 变
    化。
  local function visible_start(text, width, scroll_y)
    text = text or ""

    local len = utf8.len(text) or 0
    local start = 0
    local start_y = 0
    local last_y = nil

    for position = 0, len do
      local _, y = cursor(
        text,
        position,
        width,
        TEXT_MEASURE_HEIGHT
      )

      if y ~= last_y then
        if y > scroll_y then
          break
        end

        start = position
        start_y = y
        last_y = y
      end
    end

    return start, start_y
  end

遇到的问题:

  1. 测量会隐式触发排版, cursor 方法不便宜。根本原因是排版结果没保存。
  2. 反向查询命中需要枚举整段文字,在大段内容也有性能问题
  3. 文本跨行拼接来实现选中高亮

BTW, 滚动裁剪可能还涉及到 BATCH 支持裁剪渲染, 不过目前我用了一些绕过的方法, 而且我觉得游戏好像都是通过 layer 变换来实现的,所以先不管.

一开始我觉得这些东西要改造 text 的话,和游戏关系不大动力不强,干脆自己写个文字材质来使用,保留排版结果,形状大概是这样:

  local text = require "ishiku.text_engine"
  local font = require "soluna.font"

  local width = 360
  local size = 16
  local color = 0xffffffff
  local align = "LT"
  local line_height = 22

  local height, actual_line_height, line_count = text.measure(
    font.cobj(),
    font_id,
    "hello world",
    width,
    0,
    size,
    color,
    align,
    line_height
  )

  local layout = text.layout(
    font.cobj(),
    font_id,
    "hello world",
    width,
    height,
    size,
    color,
    align,
    line_height
  )

  local stream = layout:stream()
  batch:add(stream, x, y)

  local cx, cy, cw, ch = layout:cursor(position)

  local position = layout:hit(mouse_x - x, mouse_y - y)

  local rects = layout:selection(start_pos, finish_pos, width, height, 1, 3, scroll_y)

  local start_pos, start_y, finish_pos = layout:visible(scroll_y, viewport_height)

layout 保留了排版信息, 可以重复查询.

因为是从实现一个材质的角度出发,所以我首先想到的就是暴露一些 C APIs:

  • font_metrics(font, font_id, size, &metrics): 计算文本总高度; line height / line count;每个 glyph / cluster 的 advance; 自动换行; cursor position; hit-test 所需的 line/cursor 数据
  • font_glyph_metrics(font, font_id, codepoint, size, &glyph): 同上
  • font_glyph_image(font, font_id, codepoint, size, &image): submit 阶段查 glyph image / atlas 位置等

你说得没错, 这几个看起来确实可以合并成一个 font query.

当然如果能直接复用 soluna text lua side 来支持这些能力, 那我省的写材质了, 这些 c api 多半也不需要了.

@yuchanns yuchanns marked this pull request as draft June 29, 2026 04:41
@yuchanns yuchanns force-pushed the feat/font-extapi-opaque branch from b67746e to 6fc00de Compare June 29, 2026 04:55
@yuchanns

yuchanns commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

合并了以后保留两个方法: font_inspect 和 font_resolve_glyph 。后者不是纯查询,会生成 SDF ,不太确定是不是应该导出,这个主要是因为自己实现 text 材质 submit 的时候需要。如果能走内置 mattext 应该就不需要。

@cloudwu

cloudwu commented Jun 29, 2026

Copy link
Copy Markdown
Owner

我想想怎么实现这个功能。不过,即使额外实现,也可以用生成好的 sdf ,为什么需要额外生成

@yuchanns

Copy link
Copy Markdown
Contributor Author

也可以用生成好的 sdf ,为什么需要额外生成

因为我现在这样的实现完全不走 mattext, 没有调用 block 也没有 submit 不会生成 sdf.

@yuchanns yuchanns force-pushed the feat/font-extapi-opaque branch from 6fc00de to a2a8df4 Compare June 29, 2026 09:43
@cloudwu

cloudwu commented Jun 29, 2026

Copy link
Copy Markdown
Owner

我感觉这个需求是:

  1. 针对屏幕位置求出在文本中的字符位置
  2. 求出文本中的一段的包围矩形,或多个矩形(如果有分行)
  3. 如果可能,在 2 的基础上画下划线或矩形。

3 可以做在文本渲染模块中,也可以只取得数据额外做。

我会试着扩展以下文本材质模块。

@yuchanns

Copy link
Copy Markdown
Contributor Author

我觉得3取得数据,额外做比较有自由度

@cloudwu

cloudwu commented Jun 30, 2026

Copy link
Copy Markdown
Owner

我今天想重构一下这个文字模块。之前写的就是根据 deepfuture 的需求演变做了点刚刚够用的。

  1. 最早的版本,只有一个函数用来排版一个字符串。接口是 mattext.block(fontcobj, fontid, 32, 0, "CV") 也就是根据 font 对象,fontid ,文本大小,文本颜色,排版的对齐规则,生成一个排版绘制函数,这个函数用来把具体文本内容转化为绘图的 batch 指令流。

  2. 随后发现还需要绘制光标。因为不想破坏写好的接口(偷懒/大多数文本显示不需要光标),就简单的返回了第二个光标位置查询函数。

1 和 2 算法上有一定的相关性,但最终需要的数据不同。1 要的是每个字符的位置信息 2 要的是在特定两个字符之间的光标的位置。实现上,把两者实现在了同一个函数中,通过参数决定要哪一份数据。

现在看起来,2 这个功能增加的非常草率。其实还会有更多的需求。

我觉得或许可以改成一个好一点的设计:

继续保留 1 的排版函数,但按需把额外的排版信息记录到一个 userdata 中。这个 userdata 可以用来做进一步的查询。比如查询光标位置,查询一个选块的区域等等。

这样, 2 的接口就挪到了后者之中。但不再是重新做一次排版,而是根据 1 步骤获得的 userdata 获取相关信息。

这是个破坏性修改,我就把 deepfuture 相关的改过来,应该影响不大。

而这一步完成之后,你可以再根据具体需求直接在此基础上增加需要的其它特性。

@yuchanns

Copy link
Copy Markdown
Contributor Author

SGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants