PurpleKingdomGames / indigo

An FP game engine for Scala.
https://indigoengine.io/
MIT License
611 stars 56 forks source link

Issue #726: Adding a Font generator to the plugin #731

Closed davesmith00000 closed 2 months ago

mprevel commented 2 months ago

Hi,

I'm just seeing this PR, I may take a look at it tonight. I just put here some ideas/ issues/ questions I encountered when I tried my PoC.

  1. plugin may allow to generate multiple fonts (so it may be easier for i18n instead of building a huge bitmap for all the characters
  2. should the generated images have a square shape, have a size that is a power of 2?
  3. should the characters to add to the bitmap be set as a list of char codes or as a string?
  4. compilation of the generated FontInfo may fail if characters are not properly escaped (double quote, or simple quote for example), a workaround is to use char codes (see below)
  5. should the plugin allow to override offsets (for rendering the character on the bitmap, for reading it with the font info)?
  6. caseSensitive parameter should be handled by the plugin

For the 4th item, here is what I've done on my PoC :

      println(
        s"""
           |    val width = ${width}
           |    val height = ${height}
           |    val charWidth = ${charWidth}
           |    val charHeight = ${charHeight}
           |    val perLine = ${charactersPerLine}
           |    val characters = ${optimOutputChars(characters)}
           |
           |    def fontChar(c: Int, n: Int) = {
           |      FontChar(
           |        character = c.toChar.toString,
           |        x = n % perLine * charWidth,
           |        y = n / perLine * charHeight,
           |        width = charWidth,
           |        height = charHeight
           |      )
           |    }
           |
           |    FontInfo(fontKey, Size(width, height), fontChar(63, characters.indexOf('?'.toInt)), Batch.fromSeq(characters.zipWithIndex.map(fontChar)), caseSensitive = true)
           |""".stripMargin
      )

  private def optimOutputChars(chars: String): String = {
    case class FromTo(from: Int, to: Option[Int] = None)
    val codes = chars.map(_.toInt) // .distinct.sorted content and order has to be the same as in the font image, if we want to perform sort or distinct it should be done before the image generation
    val ranges = codes.headOption match {
      case None => scala.List.empty[FromTo]
      case Some(start) =>
        codes.tail.foldLeft(scala.List(FromTo(start))) { case (acc, code) =>
          acc.headOption match {
            case Some(FromTo(from, None)) if code == from + 1               => FromTo(from, Some(code)) :: acc.tail
            case Some(FromTo(from, Some(previous))) if code == previous + 1 => FromTo(from, Some(code)) :: acc.tail
            case _                                                          => FromTo(code) :: acc
          }
        }
    }

    if (ranges.isEmpty)
      """(0 until 0)"""
    else
      ranges.reverse
        .map {
          case FromTo(from, None)     => s"(${from} until ${from + 1})"
          case FromTo(from, Some(to)) => s"($from to $to)"
        }
        .mkString(" ++ ")
  }      
davesmith00000 commented 2 months ago

Hey @mprevel!

Thanks again for the idea / PoC!

Let me walk through your points (quickly, I'm on my lunch break.. :smile: ). These are just my current opinions, not set in stone. Opposing views are welcome.

  1. plugin may allow to generate multiple fonts (so it may be easier for i18n instead of building a huge bitmap for all the characters

Yes, as with all the generators the idea is that you can call them as many times as you like with different params. E.g. same font different chars, same chars different font, same font same chars different size, etc.

Keep it simple, make it the user's problem to decide how to make use of it.

  1. should the generated images have a square shape, have a size that is a power of 2? should the characters to add to the bitmap be set as a list of char codes or as a string?

Undecided, I was wondering about this too. We can do that, but it isn't a requirement for WebGL 2. WebGL 1 needs pow2 image sizes but Indigo's WebGL 1 support is poor at best. However if we did want this, there are two approaches:

a. Do the current process, then expand to a pow2 size. Inefficient, will probably lead to lots of empty space, but the implementation is trivial.

b. Build a process that reorganises the chars to attempt to fit a pow2 size. Not difficult but more work.

  1. compilation of the generated FontInfo may fail if characters are not properly escaped (double quote, or simple quote for example), a workaround is to use char codes (see below)

Yes indeed, quite right. I've ignored the problem in the code so far, but it's an issue I had to deal with in the roguelike starterkit too.

Thanks for the code sample.

  1. should the plugin allow to override offsets (for rendering the character on the bitmap, for reading it with the font info)?

Trying to read that code quickly, apologies if I've misunderstood. I guess you mean for allowing the user to specify character ranges? I was going to leave that to the user. In the good old days of Flash, you could provide things like "all ASCII chars" or "abcde12345" and those where the chars that got embedded.

  1. caseSensitive parameter should be handled by the plugin

Can do, but don't forget the user can also do this in their game code, and there's a funny interplay here between what we generate and what we're given. For example, if you user asks for "abc" chars... I assume they literally mean those chars in lower case, rather than "abc" and also "ABC". So I'm tempted to say that we should always embed case sensitive...?

mprevel commented 2 months ago

Yes, as with all the generators the idea is that you can call them as many times as you like with different params. E.g. same font different chars, same chars different font, same font same chars different size, etc.

That's a good point, it's about font but also size, font color, bg color, and so on.

Thanks for the code sample.

You're welcome.

Trying to read that code quickly, apologies if I've misunderstood. I guess you mean for allowing the user to specify character ranges? I was going to leave that to the user. In the good old days of Flash, you could provide things like "all ASCII chars" or "abcde12345" and those where the chars that got embedded.

It's about the way the user sets the expected characters, the way they are handled by the image generator and the code generation for the font info. Settings the expected characters as a string is probably the most explicit way for the user et probably the most convenient since there is no need to deal with char codes. For the generation of the image it probably changes nothing. But for the generation of the font info it may change the way the code is generated and the quantity of code. If dealing with a string, we may either embed the string in the generated source and generate the font info at runtime, or not embedding it and build the full font info during the code generation. In practice we can make the assumption that when a user wants 0 he most likely wants all the other digits up to 9 and the assumption can be done for a to z, A to Z, to , ... so if dealing with char codes this can be represented as ranges. Thus if the input string is sorted (+distinct) these characters may be summarized with 1 or a limited number of ranges. It's a matter of preference between a file generated with dozens maybe hundreds of lines or a few lines with ranges(s) and a map/fold.

Can do, but don't forget the user can also do this in their game code, and there's a funny interplay here between what we generate and what we're given. For example, if you user asks for "abc" chars... I assume they literally mean those chars in lower case, rather than "abc" and also "ABC". So I'm tempted to say that we should always embed case sensitive...?

I think we agree. I mean that the current default value for font info is caseSensitive = false. So we may either enforce a value in the plugin (so this should be caseSensitive = true) or allow an override in the configuration.

davesmith00000 commented 2 months ago

I've done a bit more work, the point of use now looks like this (taken from the simplistic test I wrote):

    val options: FontOptions =
      FontOptions("my font", 16, List(' ', 'a', 'b', 'c'))
        .withMaxCharactersPerLine(16)
        .noAntiAliasing

    val files =
      IndigoGenerators("com.example.test")
        .embedFont("MyFont", sourceFontTTF, options)
        .toSourcePaths(targetDir)

Going to work on that clunky list next and make it easier to express which characters you want.

davesmith00000 commented 2 months ago

However, the above generates these:

MyFont

package com.example.test

import indigo.*

// DO NOT EDIT: Generated by Indigo.
object MyFont {

  val fontKey: FontKey = FontKey("my font")

  val fontInfo: FontInfo =
    FontInfo(
      fontKey,
      36,
      17,
      FontChar(" ", 0, 0, 9, 17)
    ).isCaseSensitive
      .addChars(
        Batch(
          FontChar(" ", 0, 0, 9, 17),
          FontChar("a", 9, 0, 9, 17),
          FontChar("b", 18, 0, 9, 17),
          FontChar("c", 27, 0, 9, 17)
        )
      )

}
mprevel commented 2 months ago

I've done a bit more work, the point of use now looks like this (taken from the simplistic test I wrote):


    val options: FontOptions =
      FontOptions("my font", 16, List(' ', 'a', 'b', 'c'))
        .withMaxCharactersPerLine(16)
        .noAntiAliasing

This API looks good to me.

val files =
  IndigoGenerators("com.example.test")
    .embedFont("MyFont", sourceFontTTF, options)
    .toSourcePaths(targetDir)


Going to work on that clunky list next and make it easier to express which characters you want.

I don't know if there is something to do for this List since it can be built easily with a " abc".toList or ('a' to 'c').toList ::: List(' '), ... So IMO, it is good like this. Nonetheless, an alternative constructor could allow to use a string if preferred.

About the code generation I was a bit misleaded by the current method ".isCaseSensitive" that looks like an accessor to a boolean value, otherwise it seems good.

I just have questions about colors. I've seen the RGB case class and I guess it comes from indigo.shared.datatypes, maybe the RGBA class from the same package could be an interesting improvement? Do you think it makes sense to allow setting the background color in FontOptions?

davesmith00000 commented 2 months ago

the current method ".isCaseSensitive" that looks like an accessor

Yeah it is a poor naming decision that I made at the dawn of time when the world was young. :shrug: :smile:

Regarding colours, I think there's two things you may be suggesting in there - unless I've misread:

  1. Support transparency drawing the characters/glyphs
  2. Draw a background colour.

Transparent characters is an interesting idea as it allows some small efficiency gain in the engine if you know that your font is always one fix transparency. But otherwise, you can do this in Indigo itself just by choosing a different material.

Background colours I'm not sure about (particularly in combination with transparent chars). Can we fill the background a solid colour? I'm sure we can. However, Indigo is not expecting it, and I fear you might end up with strange artefacts and gaps between letters or misaligned top / bottom edges and so on.

Also you can always draw your Text instance on top of a shape of any colour you like, optionally having used the BoundaryLocator to find the size of your text first.

In both cases there are simple workarounds, so for now I think I'm inclined to keep it as is, make it work, get it out there and get feedback.

I do want to work on the character range constructors a bit more though. I think something like this might be nice (made up, horrible names):

enum CharRange:
  case Exact(chars: String)
  case FromTo(from: Int, to: Int)
  case CharFromTo(from: Char, to: Char)
  case Codes(chars: Array[Char])
  ..etc..

object CharRange:
  val ASCII ...
  val EXTENDED_ASCII ...
  ..etc...

Although possibly rather than an ADT, these are all just constructors of the range type. Not sure yet, but I think this is the bit that needs options to let people express themselves easily.

davesmith00000 commented 2 months ago

Getting there, but as you correctly pointed out, there's a bunch of unrepresentable characters.

      FontOptions("my font", 16, CharSet.ASCII)
        .withMaxCharactersPerLine(16)
        .noAntiAliasing

Seems like a fundamental flaw with the FontChar type though. :thinking:

davesmith00000 commented 2 months ago

I think I'm there:

Test case:

    val options: FontOptions =
      FontOptions("my font", 16, CharSet.Alphanumeric)
        .withColor(RGB.Green)
        .withMaxCharactersPerLine(16)
        .noAntiAliasing

    val files =
      IndigoGenerators("com.example.test")
        .embedFont("MyFont", sourceFontTTF, options, targetDir / Generators.OutputDirName / "images")
        .toSourcePaths(targetDir)

Font sheet:

image

FontInfo

package com.example.test

import indigo.*

// DO NOT EDIT: Generated by Indigo.
object MyFont {

  val fontKey: FontKey = FontKey("my font")

  val fontInfo: FontInfo =
    FontInfo(
      fontKey,
      151,
      68,
      FontChar(" ", 129, 51, 9, 17)
    ).isCaseSensitive
      .addChars(
        Batch(
          FontChar("a", 0, 0, 9, 17),
          FontChar("b", 9, 0, 9, 17),
          FontChar("c", 18, 0, 9, 17),
          FontChar("d", 27, 0, 9, 17),
          FontChar("e", 36, 0, 9, 17),
          FontChar("f", 45, 0, 9, 17),
          FontChar("g", 54, 0, 9, 17),
          FontChar("h", 63, 0, 10, 17),
          FontChar("i", 73, 0, 9, 17),
          FontChar("j", 82, 0, 9, 17),
          FontChar("k", 91, 0, 9, 17),
          FontChar("l", 100, 0, 9, 17),
          FontChar("m", 109, 0, 10, 17),
          FontChar("n", 119, 0, 10, 17),
          FontChar("o", 129, 0, 9, 17),
          FontChar("p", 138, 0, 9, 17),
          FontChar("q", 0, 17, 10, 17),
          FontChar("r", 10, 17, 9, 17),
          FontChar("s", 19, 17, 9, 17),
          FontChar("t", 28, 17, 9, 17),
          FontChar("u", 37, 17, 10, 17),
          FontChar("v", 47, 17, 10, 17),
          FontChar("w", 57, 17, 10, 17),
          FontChar("x", 67, 17, 9, 17),
          FontChar("y", 76, 17, 9, 17),
          FontChar("z", 85, 17, 9, 17),
          FontChar("A", 94, 17, 10, 17),
          FontChar("B", 104, 17, 9, 17),
          FontChar("C", 113, 17, 9, 17),
          FontChar("D", 122, 17, 9, 17),
          FontChar("E", 131, 17, 9, 17),
          FontChar("F", 140, 17, 9, 17),
          FontChar("G", 0, 34, 10, 17),
          FontChar("H", 10, 34, 10, 17),
          FontChar("I", 20, 34, 9, 17),
          FontChar("J", 29, 34, 9, 17),
          FontChar("K", 38, 34, 9, 17),
          FontChar("L", 47, 34, 9, 17),
          FontChar("M", 56, 34, 10, 17),
          FontChar("N", 66, 34, 10, 17),
          FontChar("O", 76, 34, 9, 17),
          FontChar("P", 85, 34, 9, 17),
          FontChar("Q", 94, 34, 10, 17),
          FontChar("R", 104, 34, 9, 17),
          FontChar("S", 113, 34, 9, 17),
          FontChar("T", 122, 34, 9, 17),
          FontChar("U", 131, 34, 10, 17),
          FontChar("V", 141, 34, 10, 17),
          FontChar("W", 0, 51, 10, 17),
          FontChar("X", 10, 51, 9, 17),
          FontChar("Y", 19, 51, 10, 17),
          FontChar("Z", 29, 51, 9, 17),
          FontChar("0", 38, 51, 9, 17),
          FontChar("1", 47, 51, 9, 17),
          FontChar("2", 56, 51, 9, 17),
          FontChar("3", 65, 51, 9, 17),
          FontChar("4", 74, 51, 9, 17),
          FontChar("5", 83, 51, 9, 17),
          FontChar("6", 92, 51, 9, 17),
          FontChar("7", 101, 51, 10, 17),
          FontChar("8", 111, 51, 9, 17),
          FontChar("9", 120, 51, 9, 17),
          FontChar(" ", 129, 51, 9, 17)
        )
      )

}
davesmith00000 commented 2 months ago

Added to an existing test scene using a different (slightly odd) font:

image

davesmith00000 commented 2 months ago

So the only thing left to resolve is that the package aliases aren't working. I always get this bit wrong, somehow. :sweat_smile:

Luckily it's gone in Scala 3, but here we are in 2.12 because sbt. Oh well.

Note: indigoplugin.generators.CharSet, etc.

      Compile / sourceGenerators += Def.task {
        IndigoGenerators("example")
          .embedFont(
            "TestFont",
            os.pwd / "sandbox" / "assets" / "fonts" / "pixelated.ttf",
            indigoplugin.generators
              .FontOptions(
                "test font",
                32,
                indigoplugin.generators.CharSet.fromUniqueString("The quick brown fox\njumps over the\nlazy dog.")
              )
              .withColor(indigoplugin.generators.RGB.White)
              .withMaxCharactersPerLine(16)
              .noAntiAliasing,
            os.pwd / "sandbox" / "assets" / "generated"
          )
          .toSourceFiles((Compile / sourceManaged).value)
      }