changeset 411:e50c57423567

did: image & font refactoring, add texture-atlas-packing
author Sam <sam@basx.dev>
date Sat, 20 Jan 2024 20:17:03 +0700
parents a408af667e29
children 262e771e2573
files semicongine/core/fonttypes.nim semicongine/core/imagetypes.nim semicongine/engine.nim semicongine/resources.nim semicongine/resources/font.nim semicongine/resources/image.nim semicongine/resources/mesh.nim semicongine/text.nim semicongine/vulkan/image.nim tests/test_font.nim
diffstat 10 files changed, 180 insertions(+), 72 deletions(-) [+]
line wrap: on
line diff
--- a/semicongine/core/fonttypes.nim	Mon Jan 08 19:18:01 2024 +0700
+++ b/semicongine/core/fonttypes.nim	Sat Jan 20 20:17:03 2024 +0700
@@ -32,5 +32,4 @@
     fontAtlas*: Texture
     maxHeight*: int
     kerning*: Table[(Rune, Rune), float32]
-    resolution*: float32
     fontscale*: float32
--- a/semicongine/core/imagetypes.nim	Mon Jan 08 19:18:01 2024 +0700
+++ b/semicongine/core/imagetypes.nim	Sat Jan 20 20:17:03 2024 +0700
@@ -4,35 +4,52 @@
 import ./vector
 
 type
-  Pixel* = array[4, uint8]
-  ImageObject* = object
+  RGBAPixel* = array[4, uint8]
+  GrayPixel* = uint8
+  Pixel* = RGBAPixel or GrayPixel
+  ImageObject*[T: Pixel] = object
     width*: int
     height*: int
-    imagedata*: seq[Pixel]
+    imagedata*: seq[T]
+  Image*[T: Pixel] = ref ImageObject[T]
+
   Sampler* = object
     magnification*: VkFilter = VK_FILTER_LINEAR
     minification*: VkFilter = VK_FILTER_LINEAR
     wrapModeS*: VkSamplerAddressMode = VK_SAMPLER_ADDRESS_MODE_REPEAT
     wrapModeT*: VkSamplerAddressMode = VK_SAMPLER_ADDRESS_MODE_REPEAT
-
-  Image* = ref ImageObject
   Texture* = object
     name*: string
-    image*: Image
+    case isGrayscale*: bool = false
+    of false: colorImage*: Image[RGBAPixel]
+    of true: grayImage*: Image[GrayPixel]
     sampler*: Sampler
 
-converter toRGBA*(p: Pixel): Vec4f =
+proc `==`*(a, b: Texture): bool =
+  if a.isGrayscale != b.isGrayscale or a.name != b.name or a.sampler != b.sampler:
+    return false
+  elif a.isGrayscale:
+    return a.grayImage == b.grayImage
+  else:
+    return a.colorImage == b.colorImage
+
+converter toRGBA*(p: RGBAPixel): Vec4f =
   newVec4f(float32(p[0]) / 255'f32, float32(p[1]) / 255'f32, float32(p[2]) / 255'f32, float32(p[3]) / 255'f32)
+converter toGrayscale*(p: GrayPixel): float32 =
+  float32(p) / 255'f32
 
 proc `$`*(image: Image): string =
   &"{image.width}x{image.height}"
 
 proc `$`*(texture: Texture): string =
-  &"{texture.name} {texture.image}"
+  if texture.isGrayscale:
+    &"{texture.name} {texture.grayImage} (gray)"
+  else:
+    &"{texture.name} {texture.colorImage} (color)"
 
 proc `[]`*(image: Image, x, y: int): Pixel =
-  assert x < image.width
-  assert y < image.height
+  assert x < image.width, &"{x} < {image.width} is not true"
+  assert y < image.height, &"{y} < {image.height} is not true"
 
   image[].imagedata[y * image.width + x]
 
@@ -42,30 +59,25 @@
 
   image[].imagedata[y * image.width + x] = value
 
-const EMPTYPIXEL = [0'u8, 0'u8, 0'u8, 0'u8]
-proc newImage*(width, height: int, imagedata: seq[Pixel] = @[], fill=EMPTYPIXEL): Image =
+proc newImage*[T: Pixel](width, height: int, imagedata: seq[T]= @[]): Image[T] =
   assert width > 0 and height > 0
   assert imagedata.len == width * height or imagedata.len == 0
 
-  result = new Image
-  result.imagedata = (if imagedata.len == 0: newSeq[Pixel](width * height) else: imagedata)
+  result = new Image[T]
+  result.imagedata = (if imagedata.len == 0: newSeq[T](width * height) else: imagedata)
   assert width * height == result.imagedata.len
 
   result.width = width
   result.height = height
-  if fill != EMPTYPIXEL:
-    for y in 0 ..< height:
-      for x in 0 ..< width:
-        result[x, y] = fill
 
-let INVALID_TEXTURE* = Texture(name: "Invalid texture", image: newImage(1, 1, @[[255'u8, 0'u8, 255'u8, 255'u8]]), sampler: Sampler(
+let INVALID_TEXTURE* = Texture(name: "Invalid texture", isGrayscale: false, colorImage: newImage(1, 1, @[[255'u8, 0'u8, 255'u8, 255'u8]]), sampler: Sampler(
     magnification: VK_FILTER_NEAREST,
     minification: VK_FILTER_NEAREST,
     wrapModeS: VK_SAMPLER_ADDRESS_MODE_REPEAT,
     wrapModeT: VK_SAMPLER_ADDRESS_MODE_REPEAT,
   )
 )
-let EMPTY_TEXTURE* = Texture(name: "Empty texture", image: newImage(1, 1, @[[255'u8, 255'u8, 255'u8, 255'u8]]), sampler: Sampler(
+let EMPTY_TEXTURE* = Texture(name: "Empty texture", isGrayscale: false, colorImage: newImage(1, 1, @[[255'u8, 255'u8, 255'u8, 255'u8]]), sampler: Sampler(
     magnification: VK_FILTER_NEAREST,
     minification: VK_FILTER_NEAREST,
     wrapModeS: VK_SAMPLER_ADDRESS_MODE_REPEAT,
--- a/semicongine/engine.nim	Mon Jan 08 19:18:01 2024 +0700
+++ b/semicongine/engine.nim	Sat Jan 20 20:17:03 2024 +0700
@@ -213,3 +213,6 @@
   if enable != engine.fullscreen:
     engine.fullscreen = enable
     engine.window.fullscreen(engine.fullscreen)
+
+func limits*(engine: Engine): VkPhysicalDeviceLimits =
+  engine.gpuDevice().physicalDevice.properties.limits
--- a/semicongine/resources.nim	Mon Jan 08 19:18:01 2024 +0700
+++ b/semicongine/resources.nim	Sat Jan 20 20:17:03 2024 +0700
@@ -1,5 +1,6 @@
 import std/streams
 import std/strutils
+import std/sequtils
 import std/strformat
 import std/os
 import std/unicode
@@ -22,7 +23,10 @@
     Zip # Zip files
     Exe # Embeded in executable
 
-const thebundletype = parseEnum[ResourceBundlingType](BUNDLETYPE.toLowerAscii().capitalizeAscii())
+const
+  thebundletype = parseEnum[ResourceBundlingType](BUNDLETYPE.toLowerAscii().capitalizeAscii())
+  ASCII_CHARSET = PrintableChars.toSeq.toRunes
+
 var selectedMod* = "default"
 
 # resource loading
@@ -127,12 +131,17 @@
   else:
     raise newException(Exception, "Unsupported audio file type: " & path)
 
-proc loadFont*(path: string, name="", color=newVec4f(1, 1, 1, 1), resolution=100'f32): Font =
+proc loadFont*(
+  path: string,
+  name="",
+  lineHeightPixels=80'f32,
+  additional_codepoints: openArray[Rune]=[],
+  charset=ASCII_CHARSET
+): Font =
   var thename = name
   if thename == "":
     thename = path.splitFile().name
-  let defaultCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=+[{]};:,<.>/? $!@#%^&*()\"'".toRunes()
-  loadResource_intern(path).readTrueType(name, defaultCharset, color, resolution)
+  loadResource_intern(path).readTrueType(name, charset & additional_codepoints.toSeq, lineHeightPixels)
 
 proc loadMeshes*(path: string, defaultMaterial: MaterialType): seq[MeshTree] =
   loadResource_intern(path).readglTF(defaultMaterial)
--- a/semicongine/resources/font.nim	Mon Jan 08 19:18:01 2024 +0700
+++ b/semicongine/resources/font.nim	Sat Jan 20 20:17:03 2024 +0700
@@ -1,64 +1,99 @@
+import times
 import std/tables
-import std/math
+import std/strformat
 import std/streams
 import std/os
 import std/unicode
+import std/logging
 
 import ../core/vector
 import ../core/imagetypes
 import ../core/fonttypes
+import ../algorithms
+import ./image
 
 {.emit: "#define STBTT_STATIC" .}
 {.emit: "#define STB_TRUETYPE_IMPLEMENTATION" .}
 {.emit: "#include \"" & currentSourcePath.parentDir() & "/stb_truetype.h\"" .}
 
-type
-  stbtt_fontinfo {.importc, incompleteStruct .} = object
+type stbtt_fontinfo {.importc, incompleteStruct .} = object
+
+const MAX_TEXTURE_WIDTH = 4096
 
 proc stbtt_InitFont(info: ptr stbtt_fontinfo, data: ptr char, offset: cint): cint {.importc, nodecl.}
 proc stbtt_ScaleForPixelHeight(info: ptr stbtt_fontinfo, pixels: cfloat): cfloat {.importc, nodecl.}
+
 proc stbtt_GetCodepointBitmap(info: ptr stbtt_fontinfo, scale_x: cfloat, scale_y: cfloat, codepoint: cint, width, height, xoff, yoff: ptr cint): cstring {.importc, nodecl.}
+proc stbtt_GetCodepointBitmapBox(info: ptr stbtt_fontinfo, codepoint: cint, scale_x, scale_y: cfloat, ix0, iy0, ix1, iy1: ptr cint) {.importc, nodecl.}
+
 proc stbtt_GetCodepointHMetrics(info: ptr stbtt_fontinfo, codepoint: cint, advance, leftBearing: ptr cint) {.importc, nodecl.}
 proc stbtt_GetCodepointKernAdvance(info: ptr stbtt_fontinfo, ch1, ch2: cint): cint {.importc, nodecl.}
+proc stbtt_FindGlyphIndex(info: ptr stbtt_fontinfo, codepoint: cint): cint {.importc, nodecl.}
 
 proc free(p: pointer) {.importc.}
 
-proc readTrueType*(stream: Stream, name: string, codePoints: seq[Rune], color: Vec4f, resolution: float32): Font =
+proc readTrueType*(stream: Stream, name: string, codePoints: seq[Rune], lineHeightPixels: float32): Font =
   var
     indata = stream.readAll()
     fontinfo: stbtt_fontinfo
   if stbtt_InitFont(addr fontinfo, addr indata[0], 0) == 0:
     raise newException(Exception, "An error occured while loading PNG file")
 
-  result.resolution = resolution
-  result.fontscale = float32(stbtt_ScaleForPixelHeight(addr fontinfo, cfloat(resolution)))
+  result.fontscale = float32(stbtt_ScaleForPixelHeight(addr fontinfo, cfloat(lineHeightPixels)))
+
+  # ensure all codepoints are available in the font
+  for codePoint in codePoints:
+    if stbtt_FindGlyphIndex(addr fontinfo, cint(codePoint)) == 0:
+      warn &"Loading font {name}: Codepoint '{codePoint}' ({cint(codePoint)}) has no glyph"
+
   var
     offsetX = 0
     bitmaps: Table[Rune, (cstring, cint, cint)]
     topOffsets: Table[Rune, int]
+    images: seq[Image[GrayPixel]]
   for codePoint in codePoints:
     var
       width, height: cint
       offX, offY: cint
+    let
       data = stbtt_GetCodepointBitmap(
         addr fontinfo,
-        result.fontscale, result.fontscale,
+        result.fontscale,
+        result.fontscale,
         cint(codePoint),
         addr width, addr height,
         addr offX, addr offY
       )
+
+    if width > 0 and height > 0:
+      var bitmap = newSeq[GrayPixel](width * height)
+      for i in 0 ..< width * height:
+        bitmap[i] = GrayPixel(data[i])
+      images.add newImage[GrayPixel](int(width), int(height), bitmap)
+
     bitmaps[codePoint] = (data, width, height)
     result.maxHeight = max(result.maxHeight, int(height))
-    offsetX += width + 1
+    offsetX += width
     topOffsets[codePoint] = offY
+  assert offsetX < MAX_TEXTURE_WIDTH, &"Font size too big, choose a smaller lineHeightPixels when loading the font (required texture width is {offsetX} but max is {MAX_TEXTURE_WIDTH}), must be smaller than {lineHeightPixels * float(MAX_TEXTURE_WIDTH) / float(offsetX) } (approx.)"
+
+  let packed = pack(images)
+  packed.atlas.writePNG("tmp.png")
 
   result.name = name
   result.fontAtlas = Texture(
     name: name & "_texture",
-    image: newImage(offsetX, result.maxHeight + 1),
+    isGrayscale: true,
+    grayImage: newImage[GrayPixel](offsetX, result.maxHeight),
     sampler: FONTSAMPLER_SOFT
   )
 
+  for codePoint in codePoints:
+    let
+      bitmap = bitmaps[codePoint][0]
+      width = bitmaps[codePoint][1]
+      height = bitmaps[codePoint][2]
+
   offsetX = 0
   for codePoint in codePoints:
     let
@@ -69,13 +104,7 @@
     # bitmap data
     for y in 0 ..< height:
       for x in 0 ..< width:
-        let value = float32(bitmap[y * width + x])
-        result.fontAtlas.image[x + offsetX, y] = [
-          uint8(round(color.r * 255'f32)),
-          uint8(round(color.g * 255'f32)),
-          uint8(round(color.b * 255'f32)),
-          uint8(round(color.a * value))
-        ]
+        result.fontAtlas.grayImage[x + offsetX, y] = uint8(bitmap[y * width + x])
 
     # horizontal spaces:
     var advance, leftBearing: cint
@@ -84,16 +113,16 @@
     result.glyphs[codePoint] = GlyphInfo(
       dimension: newVec2f(float32(width), float32(height)),
       uvs: [
-        newVec2f(float32(offsetX) / float32(result.fontAtlas.image.width), int(height) / result.maxHeight),
-        newVec2f(float32(offsetX) / float32(result.fontAtlas.image.width), 0),
-        newVec2f(float32(offsetX + width) / float32(result.fontAtlas.image.width), 0),
-        newVec2f(float32(offsetX + width) / float32(result.fontAtlas.image.width), int(height) / result.maxHeight),
+        newVec2f((float32(offsetX) + 0.5) / float32(result.fontAtlas.grayImage.width), (float32(height) - 1.0) / float32(result.maxHeight)),
+        newVec2f((float32(offsetX) + 0.5) / float32(result.fontAtlas.grayImage.width), 0.5 / float32(result.maxHeight)),
+        newVec2f((float32(offsetX + width) - 1.0) / float32(result.fontAtlas.grayImage.width), 0.5 / float32(result.maxHeight)),
+        newVec2f((float32(offsetX + width) - 1.0) / float32(result.fontAtlas.grayImage.width), (float32(height) - 1.0) / float32(result.maxHeight)),
       ],
       topOffset: float32(topOffsets[codePoint]),
       leftOffset: float32(leftBearing) * result.fontscale,
       advance: float32(advance) * result.fontscale,
     )
-    offsetX += width + 1
+    offsetX += width
     free(bitmap)
     for codePointAfter in codePoints:
       result.kerning[(codePoint, codePointAfter)] = float32(stbtt_GetCodepointKernAdvance(
--- a/semicongine/resources/image.nim	Mon Jan 08 19:18:01 2024 +0700
+++ b/semicongine/resources/image.nim	Sat Jan 20 20:17:03 2024 +0700
@@ -1,8 +1,11 @@
-import os
+import std/os
+# import std/syncio
 import std/streams
 import std/bitops
+import std/strformat
 
 import ../core/imagetypes
+import ../core/utils
 
 const COMPRESSION_BI_RGB = 0'u32
 const COMPRESSION_BI_BITFIELDS = 3'u32
@@ -36,7 +39,7 @@
     gammaGreen: uint32 # not used yet
     gammaBlue: uint32 # not used yet
 
-proc readBMP*(stream: Stream): Image =
+proc readBMP*(stream: Stream): Image[RGBAPixel] =
   var
     bitmapFileHeader: BitmapFileHeader
     dibHeader: DIBHeader
@@ -73,13 +76,13 @@
   stream.setPosition(int(bitmapFileHeader.dataStart))
   var
     padding = ((int32(dibHeader.bitsPerPixel div 8)) * dibHeader.width) mod 4
-    data = newSeq[Pixel](dibHeader.width * abs(dibHeader.height))
+    data = newSeq[RGBAPixel](dibHeader.width * abs(dibHeader.height))
   if padding > 0:
     padding = 4 - padding
   for row in 0 ..< abs(dibHeader.height):
     for col in 0 ..< dibHeader.width:
 
-      var pixel: Pixel = [0'u8, 0'u8, 0'u8, 255'u8]
+      var pixel: RGBAPixel = [0'u8, 0'u8, 0'u8, 255'u8]
       # if we got channeld bitmasks
       if dibHeader.compression in [COMPRESSION_BI_BITFIELDS, COMPRESSION_BI_ALPHABITFIELDS]:
         var value = stream.readUint32()
@@ -106,9 +109,11 @@
 {.compile: currentSourcePath.parentDir() & "/lodepng.c" .}
 
 proc lodepng_decode32(out_data: ptr cstring, w: ptr cuint, h: ptr cuint, in_data: cstring, insize: csize_t): cuint {.importc.}
+proc lodepng_encode_memory(out_data: ptr cstring, outsize: ptr csize_t, image: cstring, w: cuint, h: cuint, colorType: cint, bitdepth: cuint): cuint {.importc.}
+
 proc free(p: pointer) {.importc.} # for some reason the lodepng pointer can only properly be freed with the native free
 
-proc readPNG*(stream: Stream): Image =
+proc readPNG*(stream: Stream): Image[RGBAPixel] =
   let indata = stream.readAll()
   var w, h: cuint
   var data: cstring
@@ -117,9 +122,45 @@
     raise newException(Exception, "An error occured while loading PNG file")
 
   let imagesize = w * h * 4
-  var imagedata = newSeq[Pixel](w * h)
+  var imagedata = newSeq[RGBAPixel](w * h)
   copyMem(addr imagedata[0], data, imagesize)
 
   free(data)
 
   result = newImage(width=int(w), height=int(h), imagedata=imagedata)
+
+proc toPNG*[T: Pixel](image: Image[T]): seq[uint8] =
+  when T is GrayPixel:
+    let pngType = 0 # hardcoded in lodepng.h
+  else:
+    let pngType = 6 # hardcoded in lodepng.h
+  var
+    pngData: cstring 
+    pngSize: csize_t
+  for y in 0 ..< image.height:
+    for x in 0 ..< image.width:
+      discard
+      # stdout.write image[x, y]
+      # stdout.write ' '
+    # echo ""
+  let ret = lodepng_encode_memory(
+    addr pngData,
+    addr pngSize,
+    cast[cstring](image.imagedata.toCPointer),
+    cuint(image.width),
+    cuint(image.height),
+    cint(pngType),
+    8,
+  )
+  assert ret == 0, &"There was an error with generating the PNG data for image {image}, result was: {ret}"
+  result = newSeq[uint8](pngSize)
+  for i in 0 ..< pngSize:
+    result[i] = uint8(pngData[i])
+  free(pngData)
+
+proc writePNG*[T: Pixel](image: Image[T], filename: string) =
+  let f = filename.open(mode=fmWrite)
+  let data = image.toPNG()
+  let written = f.writeBytes(data, 0, data.len)
+  assert written == data.len, &"There was an error while saving '{filename}': only {written} of {data.len} bytes were written"
+  f.close()
--- a/semicongine/resources/mesh.nim	Mon Jan 08 19:18:01 2024 +0700
+++ b/semicongine/resources/mesh.nim	Sat Jan 20 20:17:03 2024 +0700
@@ -127,7 +127,7 @@
   else:
     copyMem(dstPointer, addr mainBuffer[bufferOffset], length)
 
-proc loadImage(root: JsonNode, imageIndex: int, mainBuffer: seq[uint8]): Image =
+proc loadImage(root: JsonNode, imageIndex: int, mainBuffer: seq[uint8]): Image[RGBAPixel] =
   if root["images"][imageIndex].hasKey("uri"):
     raise newException(Exception, "Unsupported feature: Load images from external files")
 
@@ -145,7 +145,8 @@
 
 proc loadTexture(root: JsonNode, textureIndex: int, mainBuffer: seq[uint8]): Texture =
   let textureNode = root["textures"][textureIndex]
-  result.image = loadImage(root, textureNode["source"].getInt(), mainBuffer)
+  result = Texture(isGrayscale: false)
+  result.colorImage = loadImage(root, textureNode["source"].getInt(), mainBuffer)
   result.name = root["images"][textureNode["source"].getInt()]["name"].getStr()
   if result.name == "":
     result.name = &"Texture{textureIndex}"
--- a/semicongine/text.nim	Mon Jan 08 19:18:01 2024 +0700
+++ b/semicongine/text.nim	Sat Jan 20 20:17:03 2024 +0700
@@ -15,13 +15,14 @@
     Left
     Center
     Right
-  Textbox* = object
+  Text* = object
     maxLen*: int
     text: seq[Rune]
     dirty: bool
     alignment*: TextAlignment = Center
     font*: Font
     mesh*: Mesh
+    color*: Vec4f
 
 const
   TRANSFORM_ATTRIB = "transform"
@@ -30,7 +31,7 @@
   TEXT_MATERIAL_TYPE* = MaterialType(
     name: "default-text-material-type",
     vertexAttributes: {TRANSFORM_ATTRIB: Mat4F32, POSITION_ATTRIB: Vec3F32, UV_ATTRIB: Vec2F32}.toTable,
-    attributes: {"fontAtlas": TextureType}.toTable,
+    attributes: {"fontAtlas": TextureType, "color": Vec4F32}.toTable,
   )
   TEXT_SHADER* = createShaderConfiguration(
     inputs=[
@@ -40,21 +41,29 @@
     ],
     intermediates=[attr[Vec2f]("uvFrag")],
     outputs=[attr[Vec4f]("color")],
+    uniforms=[attr[Vec4f]("color")],
     samplers=[attr[Texture]("fontAtlas")],
     vertexCode= &"""gl_Position = vec4({POSITION_ATTRIB}, 1.0) * {TRANSFORM_ATTRIB}; uvFrag = {UV_ATTRIB};""",
-    fragmentCode= &"""color = texture(fontAtlas, uvFrag);""",
+    fragmentCode= &"""color = vec4(Uniforms.color.rgb, Uniforms.color.a * texture(fontAtlas, uvFrag).r);"""
   )
 
-proc updateMesh(textbox: var Textbox) =
+proc updateMesh(textbox: var Text) =
 
   # pre-calculate text-width
   var width = 0'f32
+  var maxWidth = 0'f32
+  var height = 0 # todo: finish implementation to handle newline, start here
+  const newline = ['\n'].toRunes()[0]
   for i in 0 ..< min(textbox.text.len, textbox.maxLen):
+    if textbox.text[i] == newline:
+      maxWidth = max(width, maxWidth)
+      width = 0'f32
     width += textbox.font.glyphs[textbox.text[i]].advance
     if i < textbox.text.len - 1:
       width += textbox.font.kerning[(textbox.text[i], textbox.text[i + 1])]
+  maxWidth = max(width, maxWidth)
 
-  let centerX = width / 2
+  let centerX = maxWidth / 2
   let centerY = textbox.font.maxHeight / 2
 
   var offsetX = 0'f32
@@ -88,16 +97,16 @@
       textbox.mesh[POSITION_ATTRIB, vertexOffset + 3] = newVec3f()
 
 
-func text*(textbox: Textbox): seq[Rune] =
+func text*(textbox: Text): seq[Rune] =
   textbox.text
 
-proc `text=`*(textbox: var Textbox, text: seq[Rune]) =
+proc `text=`*(textbox: var Text, text: seq[Rune]) =
   textbox.text = text
   textbox.updateMesh()
-proc `text=`*(textbox: var Textbox, text: string) =
+proc `text=`*(textbox: var Text, text: string) =
   `text=`(textbox, text.toRunes)
 
-proc initTextbox*(maxLen: int, font: Font, text="".toRunes): Textbox =
+proc initText*(maxLen: int, font: Font, text="".toRunes, color=newVec4f(0, 0, 0, 1)): Text =
   var
     positions = newSeq[Vec3f](int(maxLen * 4))
     indices: seq[array[3, uint16]]
@@ -109,7 +118,7 @@
       [uint16(offset + 2), uint16(offset + 3), uint16(offset + 0)],
     ]
 
-  result = Textbox(maxLen: maxLen, text: text, font: font, dirty: true)
+  result = Text(maxLen: maxLen, text: text, font: font, dirty: true)
   result.mesh = newMesh(positions = positions, indices = indices, uvs = uvs, name = &"textbox-{instanceCounter}")
   inc instanceCounter
   result.mesh[].renameAttribute("position", POSITION_ATTRIB)
@@ -117,10 +126,10 @@
   result.mesh.material = initMaterialData(
     theType=TEXT_MATERIAL_TYPE,
     name=font.name & " text",
-    attributes={"fontAtlas": initDataList(@[font.fontAtlas])},
+    attributes={"fontAtlas": initDataList(@[font.fontAtlas]), "color": initDataList(@[color])},
   )
 
   result.updateMesh()
 
-proc initTextbox*(maxLen: int, font: Font, text=""): Textbox =
-  initTextbox(maxLen=maxLen, font=font, text=text.toRunes)
+proc initText*(maxLen: int, font: Font, text="", color=newVec4f(0, 0, 0, 1)): Text =
+  initText(maxLen=maxLen, font=font, text=text.toRunes, color=color)
--- a/semicongine/vulkan/image.nim	Mon Jan 08 19:18:01 2024 +0700
+++ b/semicongine/vulkan/image.nim	Sat Jan 20 20:17:03 2024 +0700
@@ -281,7 +281,10 @@
 
 proc uploadTexture*(device: Device, texture: Texture): VulkanTexture =
   assert device.vk.valid
-  result.image = createImage(device=device, width=texture.image.width, height=texture.image.height, depth=4, data=addr texture.image.imagedata[0][0])
+  if texture.isGrayscale:
+    result.image = createImage(device=device, width=texture.grayImage.width, height=texture.grayImage.height, depth=1, data=addr texture.grayImage.imagedata[0])
+  else:
+    result.image = createImage(device=device, width=texture.colorImage.width, height=texture.colorImage.height, depth=4, data=addr texture.colorImage.imagedata[0][0])
   result.imageView = result.image.createImageView()
   result.sampler = result.image.device.createSampler(texture.sampler)
 
--- a/tests/test_font.nim	Mon Jan 08 19:18:01 2024 +0700
+++ b/tests/test_font.nim	Sat Jan 20 20:17:03 2024 +0700
@@ -9,16 +9,18 @@
 
   # build scene
   var scene = Scene(name: "main")
-  var font = loadFont("DejaVuSans.ttf", color=newVec4f(1, 0.5, 0.5, 1), resolution=20)
-  var textbox = initTextbox(32, font, "")
+  # var font = loadFont("DejaVuSans.ttf", lineHeightPixels=90'f32, charset="abcdefghijklmnopqrstuvwxyz ".toRunes)
+  var font = loadFont("DejaVuSans.ttf", lineHeightPixels=90'f32)
+  var textbox = initText(32, font, "", color=newVec4f(1, 0, 0, 1))
+  let fontscale = 0.005
   scene.add textbox
-  textbox.mesh.transform = scale(0.01, 0.01)
+  textbox.mesh.transform = scale(fontscale, fontscale)
   engine.loadScene(scene)
 
   while engine.updateInputs() == Running and not engine.keyIsDown(Escape):
     if engine.windowWasResized():
       var winSize = engine.getWindow().size
-      textbox.mesh.transform = scale(0.01 * (winSize[1] / winSize[0]), 0.01)
+      textbox.mesh.transform = scale(fontscale * (winSize[1] / winSize[0]), fontscale)
     for c in [Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M, Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z]:
       if engine.keyWasPressed(c):
         if engine.keyIsDown(ShiftL) or engine.keyIsDown(ShiftR):