From 15ad69b0fe1b1a7ef6e3d0627bd2d1754ef5f44d Mon Sep 17 00:00:00 2001 From: Lars Mueller Date: Sat, 10 Jun 2023 14:56:07 +0200 Subject: [PATCH] Add highly experimental texture generation --- init.lua | 3 +- minetest/texmod.lua | 3 +- minetest/texmod/calc_dims.lua | 3 +- minetest/texmod/gen_tex.lua | 190 +++++++++++++++++ tex.lua | 386 ++++++++++++++++++++++++++++++++++ 5 files changed, 581 insertions(+), 4 deletions(-) create mode 100644 minetest/texmod/gen_tex.lua create mode 100644 tex.lua diff --git a/init.lua b/init.lua index 6141cc7..de53983 100644 --- a/init.lua +++ b/init.lua @@ -26,7 +26,8 @@ for _, file in pairs{ "base64", "persistence", "debug", - "web" + "web", + "tex" } do modules[file] = file end diff --git a/minetest/texmod.lua b/minetest/texmod.lua index 5251b10..6553b6e 100644 --- a/minetest/texmod.lua +++ b/minetest/texmod.lua @@ -9,7 +9,8 @@ local texmod, metatable = component"dsl" local methods = metatable.__index methods.write = component"write" texmod.read = component("read", texmod) -methods.calc_dims = component("calc_dims", texmod) +methods.calc_dims = component"calc_dims" +methods.gen_tex = component"gen_tex" function metatable:__tostring() local rope = {} diff --git a/minetest/texmod/calc_dims.lua b/minetest/texmod/calc_dims.lua index 19e1764..292e937 100644 --- a/minetest/texmod/calc_dims.lua +++ b/minetest/texmod/calc_dims.lua @@ -46,7 +46,6 @@ do return over_w, over_h end cd.blit = upscale_to_higher_res - cd.overlay = upscale_to_higher_res cd.hardlight = upscale_to_higher_res end @@ -95,4 +94,4 @@ function cd:png() return png.width, png.height end -return calc_dims \ No newline at end of file +return calc_dims diff --git a/minetest/texmod/gen_tex.lua b/minetest/texmod/gen_tex.lua new file mode 100644 index 0000000..acedd0b --- /dev/null +++ b/minetest/texmod/gen_tex.lua @@ -0,0 +1,190 @@ +local tex = modlib.tex + +local paths = modlib.minetest.media.paths +local function read_png(fname) + if fname == "blank.png" then return tex.new{w=1,h=1,0} end + return tex.read_png(assert(paths[fname])) +end + +local gt = {} + +-- TODO colorizehsl, hsl, contrast +-- TODO (...) inventorycube; this is nontrivial. + +function gt:file() + return read_png(self.filename) +end + +function gt:opacity() + local t = self.base:gen_tex() + t:opacity(self.ratio / 255) + return t +end + +function gt:invert() + local t = self.base:gen_tex() + t:invert(self.r, self.g, self.b, self.a) + return t +end + +function gt:brighten() + local t = self.base:gen_tex() + t:brighten() + return t +end + +function gt:noalpha() + local t = self.base:gen_tex() + t:noalpha() + return t +end + +function gt:makealpha() + local t = self.base:gen_tex() + t:makealpha(self.r, self.g, self.b) + return t +end + +function gt:multiply() + local c = self.color + local t = self.base:gen_tex() + t:multiply_rgb(c.r, c.g, c.b) + return t +end + +function gt:screen() + local c = self.color + local t = self.base:gen_tex() + t:screen_blend_rgb(c.r, c.g, c.b) + return t +end + +function gt:colorize() + local c = self.color + local t = self.base:gen_tex() + t:colorize(c.r, c.g, c.b, self.ratio) + return t +end + +local function resized_to_larger(a, b) + if a.w * a.h > b.w * b.h then + b = b:resized(a.w, a.h) + else + a = a:resized(b.w, b.h) + end + return a, b +end + +function gt:mask() + local a, b = resized_to_larger(self.base:gen_tex(), self._mask:gen_tex()) + a:band(b) + return a +end + +function gt:lowpart() + local t = self.base:gen_tex() + local over = self.over:gen_tex() + local lowpart_h = math.ceil(self.percent/100 * over.h) -- TODO (?) ceil or floor + if lowpart_h > 0 then + t, over = resized_to_larger(t, over) + local y = over.h - lowpart_h + 1 + over:crop(1, y, over.w, over.h) + t:blit(1, y, over) + end + return t +end + +function gt:resize() + return self.base:gen_tex():resized(self.w, self.h) +end + +function gt:combine() + local t = tex.filled(self.w, self.h, 0) + for _, blt in ipairs(self.blits) do + t:blit(blt.x + 1, blt.y + 1, blt.texture:gen_tex()) + end + return t +end + +function gt:fill() + if self.base then + return self.base:gen_tex():fill(self.w, self.h, self.x, self.y, self.color:to_number()) + end + return tex.filled(self.w, self.h, self.color:to_number()) +end + +function gt:blit() + local t, o = resized_to_larger(self.base:gen_tex(), self.over:gen_tex()) + t:blit(1, 1, o) + return t +end + +function gt:hardlight() + local t, o = resized_to_larger(self.base:gen_tex(), self.over:gen_tex()) + t:hardlight_blend(o) + return t +end + +-- TODO (...?) optimize this +function gt:transform() + local t = self.base:gen_tex() + if self.flip_axis == "x" then + t:flip_x() + elseif self.flip_axis == "y" then + t:flip_y() + end + -- TODO implement counterclockwise rotations to get rid of this hack + for _ = 1, 360 - self.rotation_deg / 90 do + t = t:rotated_90() + end + return t +end + +local frame = function(t, frame, framecount) + local fh = math.floor(t.h / framecount) + t:crop(1, frame * fh + 1, t.w, (frame + 1) * fh) +end + +local crack = function(self, o) + local crack = read_png"crack_anylength.png" + frame(crack, self.frame, math.floor(crack.h / crack.w)) + local t = self.base:gen_tex() + local tile_w, tile_h = math.floor(t.w / self.tilecount), math.floor(t.h / self.framecount) + crack = crack:resized(tile_w, tile_h) + for ty = 1, t.h, tile_h do + for tx = 1, t.w, tile_w do + t[o and "blito" or "blit"](t, tx, ty, crack) + end + end + return t +end + +function gt:crack() + return crack(self, false) +end + +function gt:cracko() + return crack(self, true) +end + +function gt:verticalframe() + local t = self.base:gen_tex() + frame(t, self.frame, self.framecount) + return t +end + +function gt:sheet() + local t = self.base:gen_tex() + local tw, th = math.floor(t.w / self.w), math.floor(t.h / self.h) + local x, y = self.x, self.y + t:crop(x * tw + 1, y * th + 1, (x + 1) * tw, (y + 1) * th) + return t +end + +function gt:png() + return tex.read_png_string(self.data) +end + +return function(self) + return assert(gt[self.type])(self) +end diff --git a/tex.lua b/tex.lua new file mode 100644 index 0000000..006cded --- /dev/null +++ b/tex.lua @@ -0,0 +1,386 @@ +--[[ + This file does not follow the usual conventions; + it duplicates some code for performance reasons. + + In particular, use of `modlib.minetest.colorspec` is avoided. + + Most methods operate *in-place* (imperative method names) + rather than returning a modified copy (past participle method names). + + Outside-facing methods consistently use 1-based indexing; indices are inclusive. +]] + +local min, max, floor, ceil = math.min, math.max, math.floor, math.ceil +local function round(x) return floor(x + 0.5) end +local function clamp(x, mn, mx) return max(min(x, mx), mn) end + +-- ARGB handling utilities + +local function unpack_argb(argb) + return floor(argb / 0x1000000), + floor(argb / 0x10000) % 0x100, + floor(argb / 0x100) % 0x100, + argb % 0x100 +end + +local function pack_argb(a, r, g, b) + local argb = (((a * 0x100 + r) * 0x100) + g) * 0x100 + b + return argb +end + +local function round_argb(a, r, g, b) + return round(a), round(r), round(g), round(b) +end + +local function scale_0_1_argb(a, r, g, b) + return a / 255, r / 255, g / 255, b / 255 +end + +local function scale_0_255_argb(a, r, g, b) + return a * 255, r * 255, g * 255, b * 255 +end + +local tex = {} +local metatable = {__index = tex} + +function metatable:__eq(other) + if self.w ~= other.w or self.h ~= other.h then return false end + for i = 1, #self do if self[i] ~= other[i] then return false end end + return true +end + +function tex:new() + return setmetatable(self, metatable) +end + +function tex.filled(w, h, argb) + local self = {w = w, h = h} + for i = 1, w*h do + self[i] = argb + end + return tex.new(self) +end + +function tex:copy() + local copy = {w = self.w, h = self.h} + for i = 1, #self do + copy[i] = self[i] + end + return tex.new(copy) +end + +-- Reading & writing + +function tex.read_png_string(str) + local stream = modlib.text.inputstream(str) + local png = modlib.minetest.decode_png(stream) + assert(stream:read(1) == nil, "eof expected") + modlib.minetest.convert_png_to_argb8(png) + png.data.w, png.data.h = png.width, png.height + return tex.new(png.data) +end + +function tex.read_png(path) + local png + modlib.file.with_open(path, "rb", function(f) + png = modlib.minetest.decode_png(f) + assert(f:read(1) == nil, "eof expected") + end) + modlib.minetest.convert_png_to_argb8(png) + png.data.w, png.data.h = png.width, png.height + return tex.new(png.data) +end + +function tex:write_png_string() + return modlib.minetest.encode_png(self.w, self.h, self) +end + +function tex:write_png(path) + modlib.file.write_binary(path, self:write_png_string()) +end + +function tex:fill(sx, sy, argb) + local w, h = self.w, self.h + for y = sy, h do + local i = (y - 1) * w + sx + for _ = sx, w do + self[i] = argb + i = i + 1 + end + end +end + +function tex:in_bounds(x, y) + return x >= 1 and y >= 1 and x <= self.w and y <= self.h +end + +function tex:get_argb_packed(x, y) + return self[(y - 1) * self.w + x] +end + +function tex:get_argb(x, y) + return unpack_argb(self[(y - 1) * self.w + x]) +end + +function tex:set_argb_packed(x, y, argb) + self[(y - 1) * self.w + x] = argb +end + +function tex:set_argb(x, y, a, r, g, b) + self[(y - 1) * self.w + x] = pack_argb(a, r, g, b) +end + +function tex:map_argb(func) + for i = 1, #self do + self[i] = pack_argb(func(unpack_argb(self[i]))) + end +end + +local function blit(s, x, y, t, o) + local sw, sh = s.w, s.h + local tw, th = t.w, t.h + -- Restrict to overlapping region + x, y = clamp(x, 1, sw), clamp(y, 1, sh) + local min_tx, min_ty = max(1, 2 - x), max(1, 2 - y) + local max_tx, max_ty = min(tw, sw - x + 1), min(th, sh - y + 1) + for ty = min_ty, max_ty do + local ti, si = (ty - 1) * tw, (y + ty - 2) * sw + x - 1 + for _ = min_tx, max_tx do + ti, si = ti + 1, si + 1 + local sa, sr, sg, sb = scale_0_1_argb(unpack_argb(s[si])) + if sa == 1 or not o then -- HACK because of dirty `[cracko` + local ta, tr, tg, tb = scale_0_1_argb(unpack_argb(t[ti])) + -- "`t` over `s`" (Porter-Duff-Algorithm) + local sata = sa * (1 - ta) + local ra = ta + sata + assert(ra > 0 or (sa == 0 and ta == 0)) + if ra > 0 then + s[si] = pack_argb(round_argb(scale_0_255_argb( + ra, + (ta * tr + sata * sr) / ra, + (ta * tg + sata * sg) / ra, + (ta * tb + sata * sb) / ra + ))) + end + end + end + end +end + +-- Blitting with proper alpha blending. +function tex.blit(s, x, y, t) + return blit(s, x, y, t, false) +end + +-- Blit, but only on fully opaque base pixels. Only `[cracko` uses this. +function tex.blito(s, x, y, t) + return blit(s, x, y, t, true) +end + +function tex.combine_argb(s, t, cf) + assert(#s == #t) + for i = 1, #s do + s[i] = cf(s[i], t[i]) + end +end + +-- See https://github.com/TheAlgorithms/Lua/blob/162c4c59f5514c6115e0add8a2b4d56afd6d3204/src/bit/uint53/and.lua +-- TODO (?) optimize fallback band using caching, move somewhere else +local band = bit and bit.band or function(n, m) + local res = 0 + local bit = 1 + while n * m ~= 0 do -- while both are nonzero + local n_bit, m_bit = n % 2, m % 2 -- extract LSB + res = res + (n_bit * m_bit) * bit -- add AND of LSBs + n, m = (n - n_bit) / 2, (m - m_bit) / 2 -- remove LSB from n & m + bit = bit * 2 -- next bit + end + return res +end + +function tex.band(s, t) + return s:combine_argb(t, band) +end + +function tex.hardlight_blend(s, t) + return s:combine_argb(t, function(sargb, targb) + local sa, sr, sg, sb = scale_0_1_argb(unpack_argb(sargb)) + local _, tr, tg, tb = scale_0_1_argb(unpack_argb(targb)) + return pack_argb(round_argb(scale_0_255_argb( + sa, + sr < 0.5 and 2*sr*tr or 1 - 2*(1-sr)*(1-tr), + sr < 0.5 and 2*sg*tg or 1 - 2*(1-sg)*(1-tg), + sr < 0.5 and 2*sb*tb or 1 - 2*(1-sb)*(1-tb) + ))) + end) +end + +function tex:brighten() + return self:map_argb(function(a, r, g, b) + return round_argb((255 + a) / 2, (255 + r) / 2, (255 + g) / 2, (255 + b) / 2) + end) +end + +function tex:noalpha() + for i = 1, #self do + self[i] = 0xFF000000 + self[i] % 0x1000000 + end +end + +function tex:makealpha(r, g, b) + local mrgb = r * 0x10000 + g * 0x100 + b + for i = 1, #self do + local rgb = self[i] % 0x1000000 + if rgb == mrgb then + self[i] = rgb + end + end +end + +function tex:opacity(factor) + for i = 1, #self do + self[i] = round(floor(self[i] / 0x1000000) * factor) * 0x1000000 + self[i] % 0x1000000 + end +end + +function tex:invert(ir, ig, ib, ia) + return self:map_argb(function(a, r, g, b) + if ia then a = 255 - a end + if ir then r = 255 - r end + if ig then g = 255 - g end + if ib then b = 255 - b end + return a, r, g, b + end) +end + +function tex:multiply_rgb(r, g, b) + return self:map_argb(function(sa, sr, sg, sb) + return round_argb(sa, r * sr, g * sg, b * sb) + end) +end + +function tex:screen_blend_rgb(r, g, b) + return self:map_argb(function(sa, sr, sg, sb) + return round_argb(sa, + 255 - ((255 - sr) * (255 - r)) / 255, + 255 - ((255 - sg) * (255 - g)) / 255, + 255 - ((255 - sb) * (255 - b)) / 255) + end) +end + +function tex:colorize(cr, cg, cb, ratio) + return self:map_argb(function(a, r, g, b) + local rat = ratio == "alpha" and a or ratio + return round_argb( + a, + rat * r + (1 - rat) * cr, + rat * g + (1 - rat) * cg, + rat * b + (1 - rat) * cb + ) + end) +end + +function tex:crop(from_x, from_y, to_x, to_y) + local w = self.w + local i = 1 + for y = from_y, to_y do + local j = (y - 1) * w + from_x + for _ = from_x, to_x do + self[i] = self[j] + i, j = i + 1, j + 1 + end + end + -- Remove remaining pixels + for j = i, #self do self[j] = nil end + self.w, self.h = to_x - from_x + 1, to_y - from_y + 1 +end + +function tex:flip_x() + for y = 1, self.h do + local i = (y - 1) * self.w + local j = i + self.w + 1 + while i < j do + i, j = i + 1, j - 1 + self[i], self[j] = self[j], self[i] + end + end +end + +function tex:flip_y() + for x = 1, self.w do + local i, j = x, (self.h - 1) * self.w + x + while i < j do + i, j = i + self.w, j - self.w + self[i], self[j] = self[j], self[i] + end + end +end + +--> copy of the texture, rotated 90 degrees clockwise +function tex:rotated_90() + local w, h = self.w, self.h + local t = {w = h, h = w} + local i = 0 + for y = 1, w do + for x = 1, h do + i = i + 1 + t[i] = self[(h-x)*w + y] + end + end + t = tex.new(t) + return t +end + +-- Uses box sampling. Hard to optimize. +-- TODO (...) interpolate box samples; match what Minetest does +--> copy of `self` resized to `w` x `h` +function tex:resized(w, h) + --! This function works with 0-based indices. + local sw, sh = self.w, self.h + local fx, fy = sw / w, sh / h + local t = {w = w, h = h} + local i = 0 + for y = 0, h - 1 do + for x = 0, w - 1 do + -- Sample the area + local vy_from = y * fy + local vy_to = vy_from + fy + local vx_from = x * fx + local vx_to = vx_from + fx + + local a, r, g, b = 0, 0, 0, 0 + local pf_sum = 0 + + local function blend(sx, sy, pf) + if pf <= 0 then return end + local sa, sr, sg, sb = unpack_argb(self[sy * sw + sx + 1]) + pf_sum = pf_sum + pf -- TODO (?) eliminate `pf_sum` + sa = sa * pf + a = a + sa + r, g, b = r + sa * sr, g + sa * sg, b + sa * sb + end + + local function srow(sy, pf) + if pf <= 0 then return end + local sx_from, sx_to = ceil(vx_from), floor(vx_to) + for sx = sx_from, sx_to - 1 do blend(sx, sy, pf) end -- whole pixels + -- Pixels at edges + blend(floor(vx_from), sy, pf * (sx_from - vx_from)) + blend(floor(vx_to), sy, pf * (vx_to - sx_to)) + end + + local sy_from, sy_to = ceil(vy_from), floor(vy_to) + for sy = sy_from, sy_to - 1 do srow(sy, 1) end -- whole pixels + -- Pixels at edges + srow(floor(vy_from), sy_from - vy_from) + srow(floor(vy_to), vy_to - sy_to) + if a > 0 then r, g, b = r / a, g / a, b / a end + assert(pf_sum > 0) + i = i + 1 + t[i] = pack_argb(round_argb(a / pf_sum, r, g, b)) + end + end + return tex.new(t) +end + +return tex