Add experimental texture modifier DSL

This commit is contained in:
Lars Mueller 2023-05-24 19:39:20 +02:00
parent 6ba9ec8fac
commit 9fc4aaf3b8
7 changed files with 881 additions and 0 deletions

39
doc/minetest/texmod.md Normal file

@ -0,0 +1,39 @@
# Texture Modifiers
## Specification
Refer to the following "specifications", in this order of precedence:
1. [Minetest Docs](https://github.com/minetest/minetest_docs/blob/master/doc/texture_modifiers.adoc)
2. [Minetest Lua API](https://github.com/minetest/minetest/blob/master/doc/lua_api.txt), section "texture modifiers"
3. [Minetest Sources](https://github.com/minetest/minetest/blob/master/src/client/tile.cpp)
## Implementation
### Constructors ("DSL")
Constructors are kept close to the original forms and perform basic validation. Additionally, texture modifiers can directly be created using `texmod{type = "...", ...}`, bypassing the checks.
### Writing
The naive way to implement string building would be to have a
`tostring` function recursively `tostring`ing the sub-modifiers of the current modifier;
each writer would only need a stream (often passed in the form of a `write` function).
The problem with this is that applying escaping quickly makes this run in quadratic time.
A more efficient approach passes the escaping along with the `write` function. Thus a "writer" object `w` encapsulating this state is passed around.
The writer won't necessarily produce the *shortest* or most readable texture modifier possible; for example, colors will be converted to hexadecimal representation, and texture modifiers with optional parameters may have the default values be written.
You should not rely on the writer to produce any particular of the various valid outputs.
### Reading
**The reader does not attempt to precisely match the behavior of Minetest's shotgun "parser".** It *may* be more strict in some instances, rejecting insane constructs Minetest's parser allows.
It *may* however sometimes also be more lenient (though I haven't encountered an instance of this yet), accepting sane constructs which Minetest's parser rejects due to shortcomings in its implementation.
The parser is written *to spec*, in the given order of precedence.
If a documented construct is not working, that's a bug. If a construct which is incorrect according to the docs is accepted, that's a bug too.
Compatibility with Minetest's parser for all reasonable inputs is greatly valued. If an invalid input is notably used in the wild (or it is reasonable that it may occur in the wild) and supported by Minetest, this parser ought to support it too.
Recursive descent parsing is complicated by the two forms of escaping texture modifiers support: Reading each character needs to handle escaping. The current depth and whether the parser is inside an inventorycube need to be saved in state variables. These could be passed on the stack, but it's more comfortable (and possibly more efficient) to just share them across all functions and restore them after leaving an inventorycube / moving to a lower level.

@ -9,6 +9,7 @@ for _, value in pairs{
"colorspec", "colorspec",
"media", "media",
"obj", "obj",
"texmod",
} do } do
components[value] = value components[value] = value
end end

28
minetest/texmod.lua Normal file

@ -0,0 +1,28 @@
-- Texture Modifier representation for building, parsing and stringifying texture modifiers according to
-- https://github.com/minetest/minetest_docs/blob/master/doc/texture_modifiers.adoc
local function component(component_name, ...)
return assert(loadfile(modlib.mod.get_resource(modlib.modname, "minetest", "texmod", component_name .. ".lua")))(...)
end
local texmod, metatable = component"dsl"
texmod.write = component"write"
texmod.read = component("read", texmod)
texmod.calc_dims = component"calc_dims"
function metatable:__tostring()
local rope = {}
self:write(function(str) rope[#rope+1] = str end)
return table.concat(rope)
end
function texmod.read_string(str)
local i = 0
return texmod.read(function()
i = i + 1
if i > #str then return end
return str:sub(i, i)
end)
end
return texmod

@ -0,0 +1,54 @@
-- TODO ensure completeness, use table of dim calculation functions
local base_dims = modlib.table.set({
"opacity",
"invert",
"brighten",
"noalpha",
"makealpha",
"lowpart",
"mask",
"colorize",
})
local floor, max, clamp = math.floor, math.max, modlib.math.clamp
local function next_pow_of_2(x)
-- I don't want to use a naive 2^ceil(log(x)/log(2)) due to possible float precision issues.
local m, e = math.frexp(x) -- x = _*2^e, _ in [0.5, 1)
if m == 0.5 then e = e - 1 end -- x = 2^(e-1)
return math.ldexp(1, e) -- 2^e, premature optimization here we go
end
return function(self, get_file_dims)
local function calc_dims(self)
local type = self.type
if type == "filename" then
return get_file_dims(self.filename)
end if base_dims[type] then
return calc_dims(self.base)
end if type == "resize" or type == "combine" then
return self.w, self.h
end if type == "overlay" then
local base_w, base_h = calc_dims(self.base)
local overlay_w, overlay_h = calc_dims(self.over)
return max(base_w, overlay_w), max(base_h, overlay_h)
end if type == "transform" then
if self.rotation_deg % 180 ~= 0 then
local base_w, base_h = calc_dims(self.base)
return base_h, base_w
end
return calc_dims(self.base)
end if type == "inventorycube" then
local top_w, top_h = calc_dims(self.top)
local left_w, left_h = calc_dims(self.left)
local right_w, right_h = calc_dims(self.right)
local d = clamp(next_pow_of_2(max(top_w, top_h, left_w, left_h, right_w, right_h)), 2, 64)
return d, d
end if type == "verticalframe" or type == "crack" or type == "cracko" then
local base_w, base_h = calc_dims(self.base)
return base_w, floor(base_h / self.framecount)
end if type == "sheet" then
local base_w, base_h = calc_dims(self.base)
return floor(base_w / self.w), floor(base_h / self.h)
end
error("unsupported texture modifier: " .. type)
end
return calc_dims(self)
end

259
minetest/texmod/dsl.lua Normal file

@ -0,0 +1,259 @@
local texmod = {}
local metatable = {__index = texmod}
local function new(self)
return setmetatable(self, metatable)
end
-- `texmod{...}` may be used to create texture modifiers, bypassing the checks
setmetatable(texmod, {__call = new})
-- Constructors / "generators"
function texmod.file(filename)
return new{
type = "filename",
filename = filename
}
end
function texmod.png(data)
assert(type(data) == "string")
return new{
type = "png",
data = data
}
end
function texmod.combine(w, h, blits)
assert(w % 1 == 0 and w > 0)
assert(h % 1 == 0 and h > 0)
for _, blit in ipairs(blits) do
assert(blit.x % 1 == 0)
assert(blit.y % 1 == 0)
assert(blit.texture)
end
return new{
type = "combine",
w = w,
h = h,
blits = blits
}
end
function texmod.inventorycube(top, left, right)
return new{
type = "inventorycube",
top = top,
left = left,
right = right
}
end
-- Methods / "modifiers"
function texmod:overlay(overlay)
return new{
type = "overlay",
base = self,
over = overlay
}
end
function texmod:brighten()
return new{
type = "brighten",
base = self,
}
end
function texmod:noalpha()
return new{
type = "noalpha",
base = self
}
end
function texmod:resize(w, h)
assert(w % 1 == 0 and w > 0)
assert(h % 1 == 0 and h > 0)
return new{
type = "resize",
base = self,
w = w,
h = h,
}
end
local function assert_uint8(num)
assert(num % 1 == 0 and num >= 0 and num <= 0xFF)
end
function texmod:makealpha(r, g, b)
assert_uint8(r); assert_uint8(g); assert_uint8(b)
return new{
type = "makealpha",
base = self,
r = r, g = g, b = b
}
end
function texmod:opacity(ratio)
assert_uint8(ratio)
return new{
type = "opacity",
base = self,
ratio = ratio
}
end
local function tobool(val)
return not not val
end
function texmod:invert(channels --[[set with keys "r", "g", "b", "a"]])
return new{
type = "invert",
base = self,
r = tobool(channels.r),
g = tobool(channels.g),
b = tobool(channels.b),
a = tobool(channels.a)
}
end
function texmod:flip(flip_axis --[["x" or "y"]])
assert(flip_axis == "x" or flip_axis == "y")
return new{
type = "transform",
base = self,
flip_axis = flip_axis
}
end
function texmod:rotate(deg)
deg = deg % 360
assert(deg % 90 == 0, "only multiples of 90° supported")
return new{
type = "transform",
base = self,
rotation_deg = deg
}
end
-- First flip, then rotate counterclockwise
function texmod:transform(flip_axis, rotation_deg)
assert(flip_axis == nil or flip_axis == "x" or flip_axis == "y")
rotation_deg = (rotation_deg or 0) % 360
assert(rotation_deg % 90 == 0, "only multiples of 90° supported")
return new{
type = "transform",
base = self,
rotation_deg = rotation_deg ~= 0 and rotation_deg or nil,
flip_axis = flip_axis
}
end
function texmod:verticalframe(framecount, frame)
assert(framecount >= 1)
assert(frame >= 0)
return new{
type = "verticalframe",
base = self,
framecount = framecount,
frame = frame
}
end
local function crack(self, name, ...)
local tilecount, framecount, frame
if select("#", ...) == 2 then
tilecount, framecount, frame = 1, ...
else
assert(select("#", ...) == 3, "invalid number of arguments")
tilecount, framecount, frame = ...
end
assert(tilecount >= 1)
assert(framecount >= 1)
assert(frame >= 0)
return new{
type = name,
base = self,
tilecount = tilecount,
framecount = framecount,
frame = frame
}
end
function texmod:crack(...)
return crack(self, "crack", ...)
end
function texmod:cracko(...)
return crack(self, "cracko", ...)
end
texmod.crack_with_opacity = texmod.cracko
function texmod:sheet(w, h, x, y)
assert(w % 1 == 0 and w >= 1)
assert(h % 1 == 0 and h >= 1)
assert(x % 1 == 0 and x < w)
assert(y % 1 == 0 and y < w)
return new{
type = "sheet",
base = self,
w = w,
h = h,
x = x,
y = y
}
end
local colorspec = modlib.minetest.colorspec
function texmod:multiply(color)
return new{
type = "multiply",
base = self,
color = colorspec.from_any(color) -- copies a given colorspec
}
end
function texmod:colorize(color, ratio)
color = colorspec.from_any(color) -- copies a given colorspec
if ratio == "alpha" then
assert(color.alpha or 0xFF == 0xFF)
else
ratio = ratio or color.alpha
assert_uint8(ratio)
if color.alpha == ratio then
ratio = nil
end
end
return new{
type = "colorize",
base = self,
color = color,
ratio = ratio
}
end
function texmod:mask(mask)
return new{
type = "mask",
base = self,
mask = mask
}
end
function texmod:lowpart(percent, overlay)
assert(percent % 1 == 0 and percent >= 0 and percent <= 100)
return new{
type = "lowpart",
base = self,
percent = percent,
over = overlay
}
end
return texmod, metatable

327
minetest/texmod/read.lua Normal file

@ -0,0 +1,327 @@
local texmod = ...
local colorspec = modlib.minetest.colorspec
local transforms = {
[0] = {},
{rotation_deg = 90},
{rotation_deg = 180},
{rotation_deg = 270},
{flip_axis = "x"},
{flip_axis = "x", rotation_deg = 90},
{flip_axis = "y"},
{flip_axis = "y", rotation_deg = 90},
}
return function(read_char)
-- TODO this currently uses closures rather than passing around a "reader" object,
-- which is inconsistent with the writer and harder to port to more static languages
local level = 0
local invcube = false
local eof = false
local escapes, character
local function peek()
if eof then return end
local expected_escapes = 0
if level > 0 then
-- Premature optimization my beloved (this is `2^(level-1)`)
expected_escapes = math.ldexp(0.5, level)
end
if character:match"[&^:]" then
if escapes == expected_escapes then return character end
elseif escapes <= expected_escapes then
return character
elseif escapes >= 2*expected_escapes then
return "\\"
end
end
local function popchar()
escapes = 0
while true do
character = read_char()
if character ~= "\\" then break end
escapes = escapes + 1
end
if character == nil then
assert(escapes == 0, "end of texmod expected")
eof = true
end
end
popchar()
local function pop()
local expected_escapes = 0
if level > 0 then
-- Premature optimization my beloved (this is `2^(level-1)`)
expected_escapes = math.ldexp(0.5, level)
end
if escapes > 0 and escapes >= 2*expected_escapes then
escapes = escapes - 2*expected_escapes
return
end
return popchar()
end
local function match(char)
if peek() == char then
pop()
return true
end
end
local function expect(char)
if not match(char) then
error(("%q expected"):format(char))
end
end
local function hat()
return match(invcube and "&" or "^")
end
local function match_charset(set)
local char = peek()
if char and char:match(set) then
pop()
return char
end
end
local function match_str(set)
local c = match_charset(set)
if not c then error ("character in " .. set .. " expected") end
local t = {c}
while true do
c = match_charset(set)
if not c then break end
table.insert(t, c)
end
return table.concat(t)
end
local function int()
local sign = 1
if match"-" then sign = -1 end
return sign * tonumber(match_str"%d")
end
local texp
local function subtexp()
level = level + 1
local res = texp()
level = level - 1
return res
end
local read_base = {
png = function()
expect":"
local base64 = match_str"[a-zA-Z0-9+/=]"
return assert(minetest.decode_base64(base64), "invalid base64")
end,
inventorycube = function()
local function read_side()
assert(not invcube, "can't nest inventorycube")
invcube = true
assert(match"{", "'{' expected")
local res = texp()
invcube = false
return res
end
local top = read_side()
local left = read_side()
local right = read_side()
return top, left, right
end,
combine = function()
expect":"
local w = int()
expect"x"
local h = int()
local blits = {}
while match":" do
if eof then break end -- we can just end with `:`, right?
local x = int()
expect","
local y = int()
expect"="
level = level + 1
local t = texp()
level = level - 1
table.insert(blits, {x = x, y = y, texture = t})
end
return w, h, blits
end,
}
local function fname()
-- This is overly permissive, as is Minetest;
-- we just allow arbitrary characters up until a character which may terminate the name.
-- Inside an inventorycube, `&` also terminates names.
return match_str(invcube and "[^:^&){]" or "[^:^){]")
end
local function basexp()
if match"(" then
local res = texp()
expect")"
return res
end
if match"[" then
local name = match_str"[a-z]"
local reader = read_base[name]
if not reader then
error("invalid texture modifier: " .. name)
end
return texmod[name](reader())
end
return texmod.file(fname())
end
local function pcolorspec()
-- Leave exact validation up to colorspec, only do a rough greedy charset matching
return assert(colorspec.from_string(match_str"[#%xa-z]"))
end
local function crack()
expect":"
local framecount = int()
expect":"
local frame = int()
if match":" then
return framecount, frame, int()
end
return framecount, frame
end
local param_readers = {
brighten = function()end,
noalpha = function()end,
resize = function()
expect":"
local w = int()
expect"x"
local h = int()
return w, h
end,
makealpha = function()
expect":"
local r = int()
expect","
local g = int()
expect","
local b = int()
return r, g, b
end,
opacity = function()
expect":"
local ratio = int()
return ratio
end,
invert = function()
expect":"
local channels = {}
while true do
local c = match_charset"[rgba]"
if not c then break end
channels[c] = true
end
return channels
end,
transform = function()
if match"I" then
return
end
local flip_axis
if match"F" then
flip_axis = assert(match_charset"[XY]", "axis expected"):lower()
end
local rot_deg
if match"R" then
rot_deg = int()
end
if flip_axis or rot_deg then
return flip_axis, rot_deg
end
local transform = assert(transforms[int()], "out of range")
return transform.flip_axis, transform.rotation_deg
end,
verticalframe = function()
expect":"
local framecount = int()
expect":"
local frame = int()
return framecount, frame
end,
crack = crack,
cracko = crack,
sheet = function()
expect":"
local w = int()
expect"x"
local h = int()
expect":"
local x = int()
expect","
local y = int()
return w, h, x, y
end,
multiply = function()
expect":"
return pcolorspec()
end,
colorize = function()
expect":"
local color = pcolorspec()
if not match":" then
return color
end
if not match"a" then
return color, int()
end
for c in ("lpha"):gmatch"." do
expect(c)
end
return color, "alpha"
end,
mask = function()
expect":"
return subtexp()
end,
lowpart = function()
expect":"
local percent = int()
assert(percent)
expect":"
return percent, subtexp()
end,
}
function texp()
local base = basexp()
while hat() do
if match"[" then
local name = match_str"[a-z]"
local param_reader = param_readers[name]
local gen_reader = read_base[name]
if not (param_reader or gen_reader) then
error("invalid texture modifier: " .. name)
end
if param_reader then
base = base[name](base, param_reader())
elseif gen_reader then
base = base:overlay(texmod[name](gen_reader()))
end
else
base = base:overlay(basexp())
end
end
return base
end
local res = texp()
assert(eof, "eof expected")
return res
end

173
minetest/texmod/write.lua Normal file

@ -0,0 +1,173 @@
local pw = {} -- parameter writers: `[type] = func(self, write)`
function pw:png(w)
w.colon(); w.str(minetest.encode_base64(self.data))
end
function pw:combine(w)
w.colon(); w.int(self.w); w.str"x"; w.str(self.h)
for _, blit in ipairs(self.blits) do
w.colon()
w.int(blit.x); w.str","; w.int(blit.y); w.str"="
w.esctex(blit.texture)
end
end
-- Consider [inventorycube{a{b{c inside [combine: no need to escape &'s (but :'s need to be escaped)
-- Consider [combine inside inventorycube: need to escape &'s
function pw:inventorycube(w)
assert(not w.inventorycube, "[inventorycube may not be nested")
local function write_side(side)
w.str"{"
w.inventorycube = true
w.tex(self[side])
w.inventorycube = false
end
write_side"top"
write_side"left"
write_side"right"
end
-- No parameters to write
pw.brighten = modlib.func.no_op
pw.noalpha = modlib.func.no_op
function pw:resize(w)
w.colon(); w.int(self.w); w.str"x"; w.int(self.h)
end
function pw:makealpha(w)
w.colon(); w.int(self.r); w.str","; w.int(self.g); w.str","; w.int(self.b)
end
function pw:opacity(w)
w.colon(); w.int(self.ratio)
end
function pw:invert(w)
w.colon()
if self.r then w.str"r" end
if self.g then w.str"g" end
if self.b then w.str"b" end
if self.a then w.str"a" end
end
function pw:transform(w)
local rot_deg, flip_axis = self.rotation_deg or 0, self.flip_axis
if rot_deg == 0 and flip_axis == nil then
w.str"I"
elseif rot_deg == 0 then
w.str(assert(({x = "FX", y = "FY"})[flip_axis]))
elseif flip_axis == nil then
w.str"R"; w.int(self.rotation_deg)
elseif rot_deg == 90 then
w.str(assert(({x = "FX", y = "FY"})[flip_axis]))
w.str"R90"
elseif rot_deg == 180 then
-- Rotating by 180° is equivalent to flipping both axes;
-- if one axis was already flipped, that is undone -
-- thus it is equivalent to flipping the other axis
w.str(assert(({x = "FY", y = "FX"})[flip_axis]))
elseif rot_deg == 270 then
-- Rotating by 270° is equivalent to first rotating by 180°, then rotating by 90°;
-- first flipping an axis and then rotating by 180°
-- is equivalent to flipping the other axis as shown above
w.str(assert(({x = "FY", y = "FX"})[flip_axis]))
w.str"R90"
end
end
function pw:verticalframe(w)
w.colon(); w.int(self.framecount); w.colon(); w.int(self.frame)
end
function pw:crack(w)
w.colon(); w.int(self.tilecount); w.colon(); w.int(self.framecount); w.colon(); w.int(self.frame)
end
pw.cracko = pw.crack
function pw:sheet(w)
w.colon(); w.int(self.w); w.str"x"; w.int(self.h); w.colon(); w.int(self.x); w.str","; w.int(self.y)
end
function pw:multiply(w)
w.colon()
w.str(self.color:to_string())
end
function pw:colorize(w)
w.colon()
w.str(self.color:to_string())
if self.ratio then
w.colon()
if self.ratio == "alpha" then
w.str"alpha"
else
w.int(self.ratio)
end
end
end
function pw:mask(w)
w.colon(); w.esctex(self.mask)
end
function pw:lowpart(w)
w.colon(); w.int(self.percent); w.colon(); w.esctex(self.over)
end
return function(self, write_str)
-- We could use a metatable here, but it wouldn't really be worth it;
-- it would save us instantiating a handful of closures at the cost of constant `__index` events
-- and having to constantly pass `self`, increasing code complexity
local w = {}
w.inventorycube = false
w.level = 0
w.str = write_str
function w.esc()
if w.level == 0 then return end
w.str(("\\"):rep(math.ldexp(0.5, w.level)))
end
function w.hat()
-- Note: We technically do not need to escape `&` inside an [inventorycube which is nested inside [combine,
-- but we do it anyways for good practice and since we have to escape `&` inside [combine inside [inventorycube.
w.esc()
w.str(w.inventorycube and "&" or "^")
end
function w.colon()
w.esc(); w.str":"
end
function w.int(int)
w.str(("%d"):format(int))
end
function w.tex(tex)
if tex.type == "filename" then
assert(not tex.filename:find"[:^\\&{[]", "invalid character in filename")
w.str(tex.filename)
return
end
if tex.base then
w.tex(tex.base)
w.hat()
end
if tex.type == "overlay" then
if tex.over.type ~= "filename" then -- TODO also exclude [png, [combine and [inventorycube (generators)
w.str"("; w.tex(tex.over); w.str")"
else
w.tex(tex.over)
end
else
w.str"["
w.str(tex.type)
pw[tex.type](tex, w)
end
end
function w.esctex(tex)
w.level = w.level + 1
w.tex(tex)
w.level = w.level - 1
end
w.tex(self)
end