changeset 270:563ca4a82931

did: overhaul some of the mesh-data uploading and transformation handling, added: text/font rendering
author Sam <sam@basx.dev>
date Tue, 30 May 2023 16:58:14 +0700
parents b5fb27b0f7a4
children 95281f2db400
files src/semicongine/core/buildconfig.nim src/semicongine/core/fonttypes.nim src/semicongine/core/imagetypes.nim src/semicongine/core/matrix.nim src/semicongine/engine.nim src/semicongine/mesh.nim src/semicongine/renderer.nim src/semicongine/resources.nim src/semicongine/resources/font.nim src/semicongine/scene.nim src/semicongine/text.nim
diffstat 11 files changed, 265 insertions(+), 99 deletions(-) [+]
line wrap: on
line diff
--- a/src/semicongine/core/buildconfig.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/core/buildconfig.nim	Tue May 30 16:58:14 2023 +0700
@@ -5,7 +5,7 @@
 import std/logging
 import std/os
 
-const ENGINENAME = "semicongine"
+const ENGINENAME* = "semicongine"
 const ENGINEVERSION* = static:
   var nimbleFile = newStringStream(staticRead("../../../semicongine.nimble"))
   var config = loadConfig(nimbleFile)
@@ -52,5 +52,10 @@
 const LOGLEVEL {.strdefine.}: string = (when DEBUG: "lvlAll" else: "lvlWarn")
 const ENGINE_LOGLEVEL* = parseEnum[Level](LOGLEVEL)
 
-const RESOURCEROOT* {.strdefine.}: string = "resources"
-const BUNDLETYPE* {.strdefine.}: string = "dir" # dir, zip, exe
+# resource bundleing settings, need to be configured per project
+const RESOURCEROOT* {.strdefine.}: string = "" # should be the "mod" directory
+const BUNDLETYPE* {.strdefine.}: string = "" # dir, zip, exe
+
+static:
+  assert RESOURCEROOT != "", ENGINENAME & " requires -d:RESOURCEROOT=resources"
+  assert BUNDLETYPE in ["dir", "zip", "exe"], ENGINENAME & " requires one of -d:BUNDLETYPE=dir -d:BUNDLETYPE=zip -d:BUNDLETYPE=exe"
--- a/src/semicongine/core/fonttypes.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/core/fonttypes.nim	Tue May 30 16:58:14 2023 +0700
@@ -1,12 +1,36 @@
 import std/tables
 import std/unicode
 
+import ./vulkanapi
 import ./imagetypes
 import ./vector
 
+var FONTSAMPLER_SOFT* = Sampler(
+    magnification: VK_FILTER_LINEAR,
+    minification: VK_FILTER_LINEAR,
+    wrapModeS: VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
+    wrapModeT: VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
+  )
+var FONTSAMPLER_HARD* = Sampler(
+    magnification: VK_FILTER_NEAREST,
+    minification: VK_FILTER_NEAREST,
+    wrapModeS: VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
+    wrapModeT: VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
+  )
+
+
 type
+  GlyphInfo* = object
+    uvs*: array[4, Vec2f]
+    dimension*: Vec2f
+    topOffset*: float32
+    leftOffset*: float32
+    advance*: float32
   Font* = object
     name*: string # used to reference fontAtlas will be referenced in shader
-    characterUVs*: Table[Rune, array[4, Vec2f]]
-    characterDimensions*: Table[Rune, Vec2f]
-    fontAtlas*: Image
+    glyphs*: Table[Rune, GlyphInfo]
+    fontAtlas*: Texture
+    maxHeight*: int
+    kerning*: Table[(Rune, Rune), float32]
+    resolution*: float32
+    fontscale*: float32
--- a/src/semicongine/core/imagetypes.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/core/imagetypes.nim	Tue May 30 16:58:14 2023 +0700
@@ -11,7 +11,6 @@
     minification*: VkFilter
     wrapModeS*: VkSamplerAddressMode
     wrapModeT*: VkSamplerAddressMode
-    filter*: VkFilter # TODO: replace with mag/minification
 
   Image* = ref ImageObject
   Texture* = object
@@ -27,17 +26,6 @@
     wrapModeT: VK_SAMPLER_ADDRESS_MODE_REPEAT,
   )
 
-proc newImage*(width, height: uint32, imagedata: seq[Pixel] = @[]): Image =
-  assert width > 0 and height > 0
-  assert uint32(imagedata.len) == width * height or imagedata.len == 0
-
-  result = new Image
-  result.imagedata = (if imagedata.len == 0: newSeq[Pixel](width * height) else: imagedata)
-  assert width * height == uint32(result.imagedata.len)
-
-  result.width = width
-  result.height = height
-
 proc `[]`*(image: Image, x, y: uint32): Pixel =
   assert x < image.width
   assert y < image.height
@@ -50,3 +38,19 @@
 
   image[].imagedata[y * image.width + x] = value
 
+const EMPTYPIXEL = [0'u8, 0'u8, 0'u8, 0'u8]
+proc newImage*(width, height: uint32, imagedata: seq[Pixel] = @[], fill=EMPTYPIXEL): Image =
+  assert width > 0 and height > 0
+  assert uint32(imagedata.len) == width * height or imagedata.len == 0
+
+  result = new Image
+  result.imagedata = (if imagedata.len == 0: newSeq[Pixel](width * height) else: imagedata)
+  assert width * height == uint32(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
+
--- a/src/semicongine/core/matrix.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/core/matrix.nim	Tue May 30 16:58:14 2023 +0700
@@ -394,3 +394,14 @@
     0,                  0,                  1 / (zFar - zNear), -zNear / (zFar - zNear),
     0,                  0,                  1,                   1,
   ])
+
+# create an orthographic perspective that will map from -1 .. 1 on all axis and keep a 1:1 aspect ratio
+# the smaller dimension (width or height) will always be 1 and the larger dimension will be larger, to keep the ratio
+func orthoWindowAspect*(windowAspect: float32): Mat4 =
+  if windowAspect > 1:
+    let space = 2 * (windowAspect - 1) / 2
+    ortho(-1, 1, -1 - space, 1 + space, -1, 1)
+  else:
+    let space = 2 * (1 / windowAspect - 1) / 2
+    ortho(-1 - space, 1 + space, -1, 1, -1, 1)
+
--- a/src/semicongine/engine.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/engine.nim	Tue May 30 16:58:14 2023 +0700
@@ -109,7 +109,7 @@
     engine.renderer.get.destroy()
   engine.renderer = some(engine.device.initRenderer(renderPass))
 
-proc addScene*(engine: var Engine, scene: Scene, vertexInput: seq[ShaderAttribute], samplers: seq[ShaderAttribute], transformAttribute="") =
+proc addScene*(engine: var Engine, scene: Scene, vertexInput: seq[ShaderAttribute], samplers: seq[ShaderAttribute], transformAttribute="transform") =
   assert engine.state != Destroyed
   assert transformAttribute == "" or transformAttribute in map(vertexInput, proc(a: ShaderAttribute): string = a.name)
   assert engine.renderer.isSome
--- a/src/semicongine/mesh.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/mesh.nim	Tue May 30 16:58:14 2023 +0700
@@ -16,6 +16,8 @@
     Big # up to 2^32 vertices
   Mesh* = ref object of Component
     instanceCount*: uint32
+    instanceTransforms: seq[Mat4] # this should not reside in data["transform"], as we will use data["transform"] to store the final transformation matrix (as derived from the scene-tree)
+    dirtyInstanceTransforms: bool
     data: Table[string, DataList]
     changedAttributes: seq[string]
     case indexType*: MeshIndexType
@@ -65,6 +67,11 @@
   assert not (attribute in mesh.data)
   mesh.data[attribute] = data
 
+proc setInstanceData*[T: GPUType|int|uint|float](mesh: var Mesh, attribute: string, data: seq[T]) =
+  assert uint32(data.len) == mesh.instanceCount
+  assert not (attribute in mesh.data)
+  mesh.data[attribute] = newDataList(data)
+
 func newMesh*(
   positions: openArray[Vec3f],
   indices: openArray[array[3, uint32|int32|uint16|int16|int]],
@@ -76,7 +83,7 @@
   assert colors.len == 0 or colors.len == positions.len
   assert uvs.len == 0 or uvs.len == positions.len
 
-  result = Mesh(instanceCount: instanceCount)
+  result = Mesh(instanceCount: instanceCount, instanceTransforms: newSeqWith(int(instanceCount), Unit4F32))
   setMeshData(result, "position", positions.toSeq)
   if colors.len > 0: setMeshData(result, "color", colors.toSeq)
   if uvs.len > 0: setMeshData(result, "uv", uvs.toSeq)
@@ -101,6 +108,7 @@
       result.indexType = Big
       for i, tri in enumerate(indices):
         result.bigIndices.add [uint32(tri[0]), uint32(tri[1]), uint32(tri[2])]
+  setInstanceData(result, "transform", newSeqWith(int(instanceCount), Unit4F32))
 
 func newMesh*(
   positions: openArray[Vec3f],
@@ -176,11 +184,6 @@
   mesh.changedAttributes.add attribute
   appendValues(mesh.data[attribute], data)
 
-proc setInstanceData*[T: GPUType|int|uint|float](mesh: var Mesh, attribute: string, data: seq[T]) =
-  assert uint32(data.len) == mesh.instanceCount
-  assert not (attribute in mesh.data)
-  mesh.data[attribute] = newDataList(data)
-
 proc updateInstanceData*[T: GPUType|int|uint|float](mesh: var Mesh, attribute: string, data: seq[T]) =
   assert uint32(data.len) == mesh.instanceCount
   assert attribute in mesh.data
@@ -215,7 +218,12 @@
       v = transform * v
 
 func rect*(width=1'f32, height=1'f32, color="ffffffff"): Mesh =
-  result = Mesh(instanceCount: 1, indexType: Small, smallIndices: @[[0'u16, 1'u16, 2'u16], [2'u16, 3'u16, 0'u16]])
+  result = Mesh(
+    instanceCount: 1,
+    indexType: Small,
+    smallIndices: @[[0'u16, 1'u16, 2'u16], [2'u16, 3'u16, 0'u16]],
+    instanceTransforms: @[Unit4F32]
+  )
 
   let
     half_w = width / 2
@@ -226,19 +234,21 @@
   setMeshData(result, "position", pos)
   setMeshData(result, "color", @[c, c, c, c])
   setMeshData(result, "uv", @[newVec2f(0, 0), newVec2f(1, 0), newVec2f(1, 1), newVec2f(0, 1)])
+  setInstanceData(result, "transform", @[Unit4F32])
 
 func tri*(width=1'f32, height=1'f32, color="ffffffff"): Mesh =
-  result = Mesh(instanceCount: 1)
+  result = Mesh(instanceCount: 1, instanceTransforms: @[Unit4F32])
   let
     half_w = width / 2
     half_h = height / 2
     colorVec = hexToColorAlpha(color)
   setMeshData(result, "position", @[newVec3f(0, -half_h), newVec3f( half_w, half_h), newVec3f(-half_w,  half_h)])
   setMeshData(result, "color", @[colorVec, colorVec, colorVec])
+  setInstanceData(result, "transform", @[Unit4F32])
 
 func circle*(width=1'f32, height=1'f32, nSegments=12'u16, color="ffffffff"): Mesh =
   assert nSegments >= 3
-  result = Mesh(instanceCount: 1, indexType: Small)
+  result = Mesh(instanceCount: 1, indexType: Small, instanceTransforms: @[Unit4F32])
 
   let
     half_w = width / 2
@@ -255,3 +265,24 @@
 
   setMeshData(result, "position", pos)
   setMeshData(result, "color", col)
+  setInstanceData(result, "transform", @[Unit4F32])
+
+proc areInstanceTransformsDirty*(mesh: var Mesh): bool =
+  result = mesh.dirtyInstanceTransforms
+  mesh.dirtyInstanceTransforms = false
+
+proc setInstanceTransform*(mesh: var Mesh, i: uint32, mat: Mat4) =
+  assert 0 <= i and i < mesh.instanceCount
+  mesh.instanceTransforms[i] = mat
+  mesh.dirtyInstanceTransforms = true
+
+proc setInstanceTransforms*(mesh: var Mesh, mat: seq[Mat4]) =
+  mesh.instanceTransforms = mat
+  mesh.dirtyInstanceTransforms = true
+
+proc getInstanceTransform*(mesh: Mesh, i: uint32): Mat4 =
+  assert 0 <= i and i < mesh.instanceCount
+  mesh.instanceTransforms[i]
+
+proc getInstanceTransforms*(mesh: Mesh): seq[Mat4] =
+  mesh.instanceTransforms
--- a/src/semicongine/renderer.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/renderer.nim	Tue May 30 16:58:14 2023 +0700
@@ -52,7 +52,7 @@
     raise newException(Exception, "Unable to create swapchain")
   result.swapchain = swapchain.get()
 
-proc setupDrawableBuffers*(renderer: var Renderer, scene: Scene, inputs: seq[ShaderAttribute], samplers: seq[ShaderAttribute], transformAttribute="") =
+proc setupDrawableBuffers*(renderer: var Renderer, scene: Scene, inputs: seq[ShaderAttribute], samplers: seq[ShaderAttribute], transformAttribute="transform") =
   assert not (scene in renderer.scenedata)
   const VERTEX_ATTRIB_ALIGNMENT = 4 # used for buffer alignment
   var data = SceneData()
@@ -163,19 +163,6 @@
       indexBufferOffset += size
     data.drawables[mesh] = drawable
 
-  #[
-  # extract textures
-  var sampler = DefaultSampler()
-  sampler.magnification = VK_FILTER_NEAREST
-  sampler.minification = VK_FILTER_NEAREST
-  # for mesh in allComponentsOfType[Mesh](scene.root):
-  for textbox in allEntitiesOfType[Textbox](scene.root):
-    if not (textbox.font.name in data.textures):
-      data.textures[textbox.font.name] = @[
-        renderer.device.uploadTexture(Texture(image: textbox.font.fontAtlas, sampler: sampler))
-      ]
-  ]#
-
   for material in scene.getMaterials():
     for textureName, texture in material.textures.pairs:
       if not data.textures.hasKey(textureName):
@@ -207,7 +194,12 @@
     
       data.descriptorPool = renderer.device.createDescriptorSetPool(poolsizes)
   
-      data.descriptorSets[pipeline.vk] = pipeline.setupDescriptors(data.descriptorPool, data.uniformBuffers[pipeline.vk], data.textures, inFlightFrames=renderer.swapchain.inFlightFrames)
+      data.descriptorSets[pipeline.vk] = pipeline.setupDescriptors(
+        data.descriptorPool,
+        data.uniformBuffers.getOrDefault(pipeline.vk, @[]),
+        data.textures,
+        inFlightFrames=renderer.swapchain.inFlightFrames
+      )
       for frame_i in 0 ..< renderer.swapchain.inFlightFrames:
         data.descriptorSets[pipeline.vk][frame_i].writeDescriptorSet()
 
@@ -221,6 +213,7 @@
   var (pdata, size) = mesh.getRawData(attribute)
   let memoryPerformanceHint = sceneData.attributeLocation[attribute]
   let bindingNumber = sceneData.attributeBindingNumber[attribute]
+
   sceneData.vertexBuffers[memoryPerformanceHint].setData(pdata, size, sceneData.drawables[mesh].bufferOffsets[bindingNumber][2])
 
 proc updateMeshData*(renderer: var Renderer, scene: Scene) =
@@ -230,8 +223,11 @@
     # if mesh transformation attribute is enabled, update the model matrix
     if renderer.scenedata[scene].transformAttribute != "":
       let transform = mesh.entity.getModelTransform()
-      if not (mesh in renderer.scenedata[scene].entityTransformationCache) or renderer.scenedata[scene].entityTransformationCache[mesh] != transform:
-        mesh.updateInstanceData(renderer.scenedata[scene].transformAttribute, @[transform])
+      if not (mesh in renderer.scenedata[scene].entityTransformationCache) or renderer.scenedata[scene].entityTransformationCache[mesh] != transform or mesh.areInstanceTransformsDirty:
+        var updatedTransform = newSeq[Mat4](int(mesh.instanceCount))
+        for i in 0 ..< mesh.instanceCount:
+          updatedTransform[i] = transform * mesh.getInstanceTransform(i)
+        mesh.updateInstanceData(renderer.scenedata[scene].transformAttribute, updatedTransform)
         renderer.scenedata[scene].entityTransformationCache[mesh] = transform
 
     # update any changed mesh attributes
@@ -246,7 +242,7 @@
 
   for i in 0 ..< renderer.renderPass.subpasses.len:
     for pipeline in renderer.renderPass.subpasses[i].pipelines.mitems:
-      if renderer.scenedata[scene].uniformBuffers[pipeline.vk].len != 0:
+      if renderer.scenedata[scene].uniformBuffers.hasKey(pipeline.vk) and renderer.scenedata[scene].uniformBuffers[pipeline.vk].len != 0:
         assert renderer.scenedata[scene].uniformBuffers[pipeline.vk][renderer.swapchain.currentInFlight].vk.valid
         var offset = 0'u64
         for uniform in pipeline.uniforms:
--- a/src/semicongine/resources.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/resources.nim	Tue May 30 16:58:14 2023 +0700
@@ -127,9 +127,12 @@
   else:
     raise newException(Exception, "Unsupported audio file type: " & path)
 
-proc loadFont*(path: string, name: string): Font =
-  let defaultCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=+[{]};:,<.>/?".toRunes()
-  loadResource_intern(path).readTrueType(name, defaultCharset)
+proc loadFont*(path: string, name="", color=newVec4f(1, 1, 1, 1), resolution=100'f32): Font =
+  var thename = name
+  if thename == "":
+    thename = path.splitFile().name
+  let defaultCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=+[{]};:,<.>/? ".toRunes()
+  loadResource_intern(path).readTrueType(name, defaultCharset, color, resolution)
 
 proc loadMesh*(path: string): Entity =
   loadResource_intern(path).readglTF()[0].root
--- a/src/semicongine/resources/font.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/resources/font.nim	Tue May 30 16:58:14 2023 +0700
@@ -1,5 +1,5 @@
-import std/strformat
 import std/tables
+import std/math
 import std/streams
 import std/os
 import std/unicode
@@ -16,57 +16,88 @@
   stbtt_fontinfo {.importc, incompleteStruct .} = object
 
 proc stbtt_InitFont(info: ptr stbtt_fontinfo, data: ptr char, offset: cint): cint {.importc, nodecl.}
-proc stbtt_ScaleForPixelHeight(info: ptr stbtt_fontinfo, pixels: float): cfloat {.importc, nodecl.}
-proc stbtt_GetCodepointBitmap(info: ptr stbtt_fontinfo, scale_x: cfloat, scale_y: cfloat, codepoint: cint, width: ptr cint, height: ptr cint, xoff: ptr cint, yoff: ptr cint): cstring {.importc, nodecl.}
-# proc free(p: pointer) {.importc.}
+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_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 readTrueType*(stream: Stream, name: string, codePoints: seq[Rune]): Font =
+proc free(p: pointer) {.importc.}
+
+proc readTrueType*(stream: Stream, name: string, codePoints: seq[Rune], color: Vec4f, resolution: 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")
 
-  let fontheight = stbtt_ScaleForPixelHeight(addr fontinfo, 100)
+  result.resolution = resolution
+  result.fontscale = float32(stbtt_ScaleForPixelHeight(addr fontinfo, cfloat(resolution)))
   var
-    charOffset: Table[Rune, uint32]
     offsetX: uint32
-    maxheight: uint32
     bitmaps: Table[Rune, (cstring, cint, cint)]
-    baselines: Table[Rune, int]
+    topOffsets: Table[Rune, int]
   for codePoint in codePoints:
     var
       width, height: cint
-      leftStart, baseline: cint
+      offX, offY: cint
       data = stbtt_GetCodepointBitmap(
         addr fontinfo,
-        0, fontheight,
-        cint('a'),
+        result.fontscale, result.fontscale,
+        cint(codePoint),
         addr width, addr height,
-        addr leftStart, addr baseline
+        addr offX, addr offY
       )
     bitmaps[codePoint] = (data, width, height)
-    maxheight = max(maxheight, uint32(height))
-    charOffset[codePoint] = offsetX
-    offsetX += uint32(width)
-    baselines[codePoint] = baseline
+    result.maxHeight = max(result.maxHeight, int(height))
+    offsetX += uint32(width + 1)
+    topOffsets[codePoint] = offY
 
   result.name = name
-  result.fontAtlas = newImage(offsetX, maxheight)
+  result.fontAtlas = Texture(
+    name: name & "_texture",
+    image: newImage(offsetX, uint32(result.maxHeight + 1)),
+    sampler: FONTSAMPLER_SOFT
+  )
 
   offsetX = 0
   for codePoint in codePoints:
-    let d = bitmaps[codePoint][0]
-    let width = uint32(bitmaps[codePoint][1])
-    let height = uint32(bitmaps[codePoint][2])
+    let
+      bitmap = bitmaps[codePoint][0]
+      width = uint32(bitmaps[codePoint][1])
+      height = uint32(bitmaps[codePoint][2])
+
+    # bitmap data
     for y in 0 ..< height:
       for x in 0 ..< width:
-        result.fontAtlas[x + offsetX, y] = [255'u8, 255'u8, 255'u8, uint8(d[y * width + x])]
-    result.characterDimensions[codePoint] = newVec2f(float32(width), float32(height))
-    result.characterUVs[codePoint] = [
-      newVec2f(float32(offsetX) / float32(result.fontAtlas.width), 0),
-      newVec2f(float32(offsetX + width) / float32(result.fontAtlas.width), 0),
-      newVec2f(float32(offsetX) / float32(result.fontAtlas.width), 1),
-      newVec2f(float32(offsetX + width) / float32(result.fontAtlas.width), 1),
-    ]
-    offsetX += 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))
+        ]
+
+    # horizontal spaces:
+    var advance, leftBearing: cint
+    stbtt_GetCodepointHMetrics(addr fontinfo, cint(codePoint), addr advance, addr leftBearing)
+
+    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),
+      ],
+      topOffset: float32(topOffsets[codePoint]),
+      leftOffset: float32(leftBearing) * result.fontscale,
+      advance: float32(advance) * result.fontscale,
+    )
+    offsetX += width + 1
+    free(bitmap)
+    for codePointAfter in codePoints:
+      result.kerning[(codePoint, codePointAfter)] = float32(stbtt_GetCodepointKernAdvance(
+        addr fontinfo,
+        cint(codePoint),
+        cint(codePointAfter)
+      )) * result.fontscale
--- a/src/semicongine/scene.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/scene.nim	Tue May 30 16:58:14 2023 +0700
@@ -31,6 +31,7 @@
     components*: seq[Component]
 
 func getModelTransform*(entity: Entity): Mat4 =
+  assert not entity.isNil
   result = Unit4
   var currentEntity = entity
   while currentEntity != nil:
@@ -156,7 +157,7 @@
 iterator allEntitiesOfType*[T: Entity](root: Entity): T =
   var queue = @[root]
   while queue.len > 0:
-    let entity = queue.pop
+    var entity = queue.pop
     if entity of T:
       yield T(entity)
     for i in countdown(entity.children.len - 1, 0):
--- a/src/semicongine/text.nim	Sun May 28 18:36:11 2023 +0700
+++ b/src/semicongine/text.nim	Tue May 30 16:58:14 2023 +0700
@@ -1,3 +1,7 @@
+import std/sequtils
+import std/tables
+import std/unicode
+
 import ./scene
 import ./mesh
 import ./core/vector
@@ -10,26 +14,82 @@
     Center
     Right
   Textbox* = ref object of Entity
-    columns*: uint32
-    rows*: uint32
-    text*: string
+    maxLen*: uint32
+    text: seq[Rune]
+    dirty: bool
     alignment*: TextAlignment
     font*: Font
-    lettermesh*: Mesh
+    mesh*: Mesh
+
+proc updateMesh(textbox: var Textbox) =
+
+  # pre-calculate text-width
+  var width = 0'f32
+  for i in 0 ..< min(uint32(textbox.text.len), textbox.maxLen):
+    width += textbox.font.glyphs[textbox.text[i]].advance
+    if i < uint32(textbox.text.len - 1):
+      width += textbox.font.kerning[(textbox.text[i], textbox.text[i + 1])]
+
+  let centerX = width / 2
+  let centerY = textbox.font.maxHeight / 2
 
-func len*(textbox: Textbox): uint32 =
-  textbox.columns * textbox.rows
+  var offsetX = 0'f32
+  for i in 0 ..< textbox.maxLen:
+    let vertexOffset = i * 4
+    if i < uint32(textbox.text.len):
+      let
+        glyph = textbox.font.glyphs[textbox.text[i]]
+        left = offsetX + glyph.leftOffset
+        right = offsetX + glyph.leftOffset + glyph.dimension.x
+        top = glyph.topOffset
+        bottom = glyph.topOffset + glyph.dimension.y
+
+      textbox.mesh.updateMeshData("position", vertexOffset + 0, newVec3f(left - centerX, bottom + centerY))
+      textbox.mesh.updateMeshData("position", vertexOffset + 1, newVec3f(left - centerX, top + centerY))
+      textbox.mesh.updateMeshData("position", vertexOffset + 2, newVec3f(right - centerX, top + centerY))
+      textbox.mesh.updateMeshData("position", vertexOffset + 3, newVec3f(right - centerX, bottom + centerY))
+
+      textbox.mesh.updateMeshData("uv", vertexOffset + 0, glyph.uvs[0])
+      textbox.mesh.updateMeshData("uv", vertexOffset + 1, glyph.uvs[1])
+      textbox.mesh.updateMeshData("uv", vertexOffset + 2, glyph.uvs[2])
+      textbox.mesh.updateMeshData("uv", vertexOffset + 3, glyph.uvs[3])
 
-proc newTextbox*(columns, rows: uint32, font: Font, text=""): Textbox =
-  result = Textbox(columns: columns, rows: rows, text: text, font: font)
-  result.lettermesh = newMesh(
-    positions = [newVec3f(0, 0), newVec3f(0, 1), newVec3f(1, 1), newVec3f(1, 0)],
-    indices = [[0'u16, 1'u16, 2'u16], [0'u16, 0'u16, 0'u16]],
-    uvs = [newVec2f(0, 0), newVec2f(0, 1), newVec2f(1, 1), newVec2f(1, 0)],
-    instanceCount = result.len,
-  )
-  var transforms = newSeq[Mat4](result.len)
-  for i in 0 ..< result.len:
-    transforms[i] = Unit4f32
-  setInstanceData(result.lettermesh, "transform", transforms)
-  result.components.add result.lettermesh
+      offsetX += glyph.advance
+      if i < uint32(textbox.text.len - 1):
+        offsetX += textbox.font.kerning[(textbox.text[i], textbox.text[i + 1])]
+    else:
+      textbox.mesh.updateMeshData("position", vertexOffset + 0, newVec3f())
+      textbox.mesh.updateMeshData("position", vertexOffset + 1, newVec3f())
+      textbox.mesh.updateMeshData("position", vertexOffset + 2, newVec3f())
+      textbox.mesh.updateMeshData("position", vertexOffset + 3, newVec3f())
+
+
+func text*(textbox: Textbox): seq[Rune] =
+  textbox.text
+
+proc `text=`*(textbox: var Textbox, text: seq[Rune]) =
+  textbox.text = text
+  textbox.name = $text
+  textbox.updateMesh()
+
+proc newTextbox*(maxLen: uint32, font: Font, text=toRunes("")): Textbox =
+  var
+    positions = newSeq[Vec3f](int(maxLen * 4))
+    indices: seq[array[3, uint32]]
+    uvs = newSeq[Vec2f](int(maxLen * 4))
+  for i in 0 ..< maxLen:
+    let offset = i * 4
+    indices.add [[offset + 0, offset + 1, offset + 2], [offset + 2, offset + 3, offset + 0]]
+
+  result = Textbox(maxLen: maxLen, text: text, font: font, dirty: true)
+  result.mesh = newMesh(positions = positions, indices = indices, uvs = uvs)
+  result.mesh.setInstanceTransforms(@[Unit4F32])
+  result.name = $text
+  result.transform = Unit4F32
+
+  # wrap the text mesh in a new entity to preserve the font-scaling
+  var box = newEntity("box", result.mesh)
+  # box.transform = scale3d(font.fontscale * 0.002, font.fontscale * 0.002)
+  box.transform = scale3d(1 / font.resolution, 1 / font.resolution)
+  result.add box
+  result.updateMesh()