Skip to content

Commit

Permalink
Improves CachedContainer with renderQuality and provides korim.Qual…
Browse files Browse the repository at this point in the history
…ity (#1803)

* Added `korlibs.image.Quality` and make `GameWindow.Quality` implement it + tests

* Add Views.quality as an alias of GameWindow::quality

* Make enumerable debug view properties to support nullable values

* Add Missing Quality.LIST

* Test Quality.LIST

* Adds QualityProvider with a ViewPropertyProvider listing available Qualities

* Adds CachedContainer.renderQuality, backporting changes from @eaboll in #1760

* Backport example from other PR, but do not change the virtual size to avoid it to be misleading; just scale the container
  • Loading branch information
soywiz authored Jul 17, 2023
1 parent c225160 commit 75e862e
Show file tree
Hide file tree
Showing 10 changed files with 195 additions and 26 deletions.
3 changes: 2 additions & 1 deletion korge-sandbox/src/commonMain/kotlin/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ suspend fun main() = Korge(
//Demo(::MainColorTransformFilter),
//Demo(::MainMasks),
//Demo(::MainShape2dScene),
Demo(::MainUIStacks),
//Demo(::MainUIStacks),
Demo(::MainCache),
//Demo(::MainSprites10k),
//Demo(::MainStressMatrixMultiplication),
//Demo(::MainSDF),
Expand Down
46 changes: 44 additions & 2 deletions korge-sandbox/src/commonMain/kotlin/samples/MainCache.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package samples

import korlibs.time.*
import korlibs.image.*
import korlibs.image.color.*
import korlibs.image.font.*
import korlibs.image.text.*
import korlibs.korge.scene.*
import korlibs.korge.time.*
import korlibs.korge.ui.*
import korlibs.korge.view.*
import korlibs.image.color.*
import korlibs.math.geom.*
import korlibs.math.interpolation.*
import korlibs.math.random.*
import korlibs.time.*
import kotlin.random.*

//class MainCache : ScaledScene(512, 512) {
Expand All @@ -28,6 +32,25 @@ class MainCache : Scene() {
uiText("children=${cached.numChildren}")
}

uiHorizontalStack {
this.position(315, 0)
scale(2)

uiScrollable(size = Size(100, 100)) {
it.sampleTextBlock(RichTextData("Default Scaling: Default", font = DefaultTtfFontAsBitmap))
}

uiScrollable(size = Size(100, 100)) {
it.renderQuality = Quality.HIGH
it.sampleTextBlock(RichTextData("Expensive Scaling: Opt-in", font = DefaultTtfFontAsBitmap))
}

uiScrollable(size = Size(100, 100)) {
it.renderQuality = Quality.LOW
it.sampleTextBlock(RichTextData("Cheap Scaling: Opt-in", font = DefaultTtfFontAsBitmap))
}
}

interval(1.seconds) {
for (n in 0 until 2000) {
cached.getChildAt(50_000 + n).colorMul = random[Colors.RED, Colors.BLUE].mix(Colors.WHITE, 0.3.toRatio())
Expand All @@ -40,4 +63,23 @@ class MainCache : Scene() {
// rect.color = Colors.BLUE
//}
}

//override suspend fun SContainer.sceneInit() {
// setVirtualSize(Korge.DEFAULT_WINDOW_SIZE / 2)
//}
//override suspend fun sceneAfterDestroy() {
// super.sceneAfterDestroy()
// setVirtualSize(Korge.DEFAULT_WINDOW_SIZE)
//}
//private fun setVirtualSize(size: Size) {
// views.setVirtualSize(size.width.toIntCeil(), size.height.toIntCeil())
//}


private fun Container.sampleTextBlock(richTextData: RichTextData): TextBlock {
return textBlock(align = TextAlignment.MIDDLE_CENTER, size = Size(100, 100)) {
text = richTextData
fill = Colors.WHITE
}
}
}
35 changes: 29 additions & 6 deletions korge/src/commonMain/kotlin/korlibs/korge/view/CachedContainer.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package korlibs.korge.view

import korlibs.datastructure.*
import korlibs.image.*
import korlibs.io.lang.*
import korlibs.korge.internal.*
import korlibs.korge.render.*
import korlibs.korge.view.property.*
import korlibs.math.geom.*
import korlibs.render.*
import kotlin.jvm.*

inline fun Container.fixedSizeCachedContainer(size: Size, cache: Boolean = true, clip: Boolean = true, callback: @ViewDslMarker CachedContainer.() -> Unit = {}) =
FixedSizeCachedContainer(size, cache, clip).addTo(this, callback)
Expand Down Expand Up @@ -35,12 +37,25 @@ open class FixedSizeCachedContainer(
}

open class CachedContainer(
/** Indicates if we are going to render this container into a texture, and reuse its content in following frames. */
@property:ViewProperty
var cache: Boolean = true
var cache: Boolean = true,
/** Affects the size of the cached texture (bigger textures on hidpi screens for high quality and smaller for low quality), when null, uses the configured [GameWindow.quality]. */
renderQuality: Quality? = null,
) : Container(), InvalidateNotifier {
//@ViewProperty
//var cache: Boolean = cache

@property:ViewProperty
@property:ViewPropertyProvider(QualityProvider::class)
var renderQuality: Quality? = renderQuality
set(value) {
if (field != value) {
field = value
dirty = true
}
}

inner class CacheTexture(val ctx: RenderContext) : Closeable {
val rb = ctx.unsafeAllocateFrameBuffer(16, 16, onlyThisFrame = false)
val texBase = TextureBase(rb.tex, 16, 16)
Expand All @@ -62,6 +77,7 @@ open class CachedContainer(
private var dirty = true
private var scaledCache = -1f
private var lbounds = Rectangle()
private var windowLocalRatio: Scale = Scale(1)

override fun invalidateRender() {
super.invalidateRender()
Expand All @@ -81,25 +97,32 @@ open class CachedContainer(
val cache = _cacheTex!!
ctx.refGcCloseable(cache)

val renderScale: Float = when (ctx.views?.gameWindow?.quality) {
val renderScale: Float = when (ctx.quality) {
GameWindow.Quality.PERFORMANCE -> 1f
else -> ctx.devicePixelRatio
}

val doExpensiveScaling = !(renderQuality ?: ctx.quality).isLow
//val renderScale = 1.0

if (dirty || scaledCache != renderScale) {
scaledCache = renderScale
lbounds = getLocalBounds(includeFilters = false)
windowLocalRatio = when {
doExpensiveScaling -> (windowBounds.size / lbounds.size)
else -> Scale(1)
}

dirty = false
val texWidth = (lbounds.width * renderScale).toInt().coerceAtLeast(1)
val texHeight = (lbounds.height * renderScale).toInt().coerceAtLeast(1)
val texWidth = (lbounds.width * renderScale * windowLocalRatio.scaleX).toInt().coerceAtLeast(1)
val texHeight = (lbounds.height * renderScale * windowLocalRatio.scaleY).toInt().coerceAtLeast(1)
cache.resize(texWidth, texHeight)
ctx.flush()
ctx.renderToFrameBuffer(cache.rb) {
//ctx.ag.clear(Colors.TRANSPARENT, clearColor = true)
ctx.setViewMatrixTemp(globalMatrixInv
.translated(-lbounds.x, -lbounds.y)
.scaled(renderScale)
.scaled(renderScale * windowLocalRatio.scaleX, renderScale * windowLocalRatio.scaleY)
) {
super.renderInternal(ctx)
}
Expand All @@ -111,7 +134,7 @@ open class CachedContainer(
cache.tex,
m = globalMatrix
.pretranslated(lbounds.x, lbounds.y)
.prescaled(1.0 / renderScale)
.prescaled(1.0 / (renderScale * windowLocalRatio.scaleX), 1.0 / (renderScale * windowLocalRatio.scaleY))
,
colorMul = renderColorMul,
blendMode = blendMode,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package korlibs.korge.view

import korlibs.image.*
import korlibs.image.text.*
import korlibs.korge.view.property.*
import korlibs.math.geom.*
Expand All @@ -24,3 +25,7 @@ object ScaleModeProvider : ViewPropertyProvider.ItemsImpl<ScaleMode>() {
object BlendModeProvider : ViewPropertyProvider.ItemsImpl<BlendMode>() {
override val ITEMS get() = BlendMode.STANDARD_LIST
}

object QualityProvider : ViewPropertyProvider.ItemsImpl<Quality>() {
override val ITEMS get() = Quality.LIST
}
2 changes: 2 additions & 0 deletions korge/src/commonMain/kotlin/korlibs/korge/view/Views.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class Views(
InvalidateNotifier,
DeviceDimensionsProvider by gameWindow
{
var quality by gameWindow::quality

override val views = this

var rethrowRenderError = false
Expand Down
26 changes: 21 additions & 5 deletions korge/src/jvmMain/kotlin/korlibs/korge/awt/UiEditProperties.kt
Original file line number Diff line number Diff line change
Expand Up @@ -192,20 +192,36 @@ internal class UiEditProperties(app: UiApplication, view: View?, val views: View
return UiFourItemEditableValue(app, vv[0], vv[1], vv[2], vv[3])
}

class WrappedValue<T>(val value: T?) {
override fun toString(): String = if (value == null) "null" else "$value"
}

fun createUiEditableValueFor(instance: Any, type: KType, viewProp: ViewProperty, prop: KProperty1<View, Any?>?, obs: ObservableProperty<*>? = null): UiComponent? {
val name = prop?.name ?: "Unknown"
val obs = obs ?: ObservableProperty<Any?>(
val obs: ObservableProperty<Any?> = (obs ?: ObservableProperty<Any?>(
name,
internalSet = { (prop as KMutableProperty1<Any, Any?>).set(instance, it) },
internalGet = { (prop as KProperty1<Any, Any?>).get(instance) }
)
)) as ObservableProperty<Any?>

prop?.findAnnotation<ViewPropertyProvider>()?.let { propertyProvider ->
val singletonClazz = propertyProvider.provider as KClass<Any>
val singletonInstance = singletonClazz.objectInstance as ViewPropertyProvider.Impl<Any, Any>
return UiListEditableValue<Any?>(app, {
singletonInstance.provider(instance).values.toList()
}, obs as ObservableProperty<Any?>)

val obs2 = ObservableProperty<WrappedValue<Any?>?>(
obs.name,
internalSet = { obs.value = it?.value },
internalGet = { WrappedValue(obs.value) }
) as ObservableProperty<Any?>

return UiListEditableValue<Any?>(
app,
{
(singletonInstance.provider(instance).values.toList() + (if (type.isMarkedNullable) listOf(null) else emptyList()))
.map { WrappedValue(it) }
},
obs2
)
}

return when {
Expand Down
8 changes: 4 additions & 4 deletions korgw/src/commonMain/kotlin/korlibs/render/GameWindow.kt
Original file line number Diff line number Diff line change
Expand Up @@ -477,13 +477,13 @@ open class GameWindow :
* [PERFORMANCE] will use lower resolutions, while [QUALITY] will use the devicePixelRatio
* to render high quality images.
*/
enum class Quality {
enum class Quality(override val level: Float) : korlibs.image.Quality {
/** Will render to lower resolutions, ignoring devicePixelRatio on retina-like screens */
PERFORMANCE,
PERFORMANCE(0f),
/** Will render to higher resolutions, using devicePixelRatio on retina-like screens */
QUALITY,
QUALITY(1f),
/** Will choose [PERFORMANCE] or [QUALITY] based on some heuristics */
AUTOMATIC;
AUTOMATIC(.5f);

private val UPPER_BOUND_RENDERED_PIXELS = 4_000_000

Expand Down
27 changes: 27 additions & 0 deletions korim/src/commonMain/kotlin/korlibs/image/Quality.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package korlibs.image

fun Quality(level: Float, name: String? = null): Quality = QualityImpl(level, name)
//interface Quality : Comparable<Quality> {
interface Quality {
val level: Float

operator fun compareTo(other: Quality): Int = this.level.compareTo(other.level)

companion object {
val LOWEST: Quality = QualityImpl(0f, "LOWEST")
val LOW: Quality = QualityImpl(.25f, "LOW")
val MEDIUM: Quality = QualityImpl(.5f, "MEDIUM")
val HIGH: Quality = QualityImpl(.75f, "HIGH")
val HIGHEST: Quality = QualityImpl(1f, "HIGHEST")

val LIST = listOf(LOWEST, LOW, MEDIUM, HIGH, HIGHEST)
}
}

val Quality.isLow: Boolean get() = level <= 0.25f
val Quality.isMedium: Boolean get() = !isLow && !isHigh
val Quality.isHigh: Boolean get() = level >= 0.75f

private data class QualityImpl(override val level: Float, val name: String? = null) : Quality {
override fun toString(): String = name ?: super.toString()
}
40 changes: 40 additions & 0 deletions korim/src/commonTest/kotlin/korlibs/image/QualityTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package korlibs.image

import korlibs.io.util.*
import kotlin.test.*

class QualityTest {
@Test
fun testLevels() {
fun Quality.result(): String = "$this: ${level.niceStr(2, zeroSuffix = true)}: $isLow, $isMedium, $isHigh : ${this <= Quality.MEDIUM}, ${this >= Quality.MEDIUM}"

assertEquals(
"""
LOWEST: 0.0: true, false, false : true, false
CUSTOM1: 0.1: true, false, false : true, false
LOW: 0.25: true, false, false : true, false
MEDIUM: 0.5: false, true, false : true, true
HIGH: 0.75: false, false, true : false, true
CUSTOM9: 0.9: false, false, true : false, true
HIGHEST: 1.0: false, false, true : false, true
""".trimIndent(),
"""
${Quality.LOWEST.result()}
${Quality(.1f, name = "CUSTOM1").result()}
${Quality.LOW.result()}
${Quality.MEDIUM.result()}
${Quality.HIGH.result()}
${Quality(.9f, name = "CUSTOM9").result()}
${Quality.HIGHEST.result()}
""".trimIndent()
)
}

@Test
fun testLevelsList() {
assertEquals(
listOf(Quality.LOWEST, Quality.LOW, Quality.MEDIUM, Quality.HIGH, Quality.HIGHEST),
Quality.LIST
)
}
}
29 changes: 21 additions & 8 deletions korio/src/commonMain/kotlin/korlibs/io/util/NumberExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,50 @@ import kotlin.math.round
fun Int.toStringUnsigned(radix: Int): String = this.toUInt().toString(radix)
fun Long.toStringUnsigned(radix: Int): String = this.toULong().toString(radix)

val Double.niceStr: String get() = buildString { appendNice(this@niceStr) }
fun Double.niceStr(decimalPlaces: Int): String = roundDecimalPlaces(decimalPlaces).niceStr
val Double.niceStr: String get() = niceStr(-1, zeroSuffix = false)
fun Double.niceStr(decimalPlaces: Int): String = niceStr(decimalPlaces, zeroSuffix = false)
fun Double.niceStr(decimalPlaces: Int, zeroSuffix: Boolean): String = buildString { appendNice(this@niceStr.roundDecimalPlaces(decimalPlaces), zeroSuffix = zeroSuffix) }

val Float.niceStr: String get() = this.toDouble().niceStr
fun Float.niceStr(decimalPlaces: Int): String = this.toDouble().niceStr(decimalPlaces)
val Float.niceStr: String get() = niceStr(-1, zeroSuffix = false)
fun Float.niceStr(decimalPlaces: Int): String = niceStr(decimalPlaces, zeroSuffix = false)
fun Float.niceStr(decimalPlaces: Int, zeroSuffix: Boolean): String = buildString { appendNice(this@niceStr.roundDecimalPlaces(decimalPlaces), zeroSuffix = zeroSuffix) }

//val Float.niceStr: String get() = buildString { appendNice(this@niceStr) }
//fun Float.niceStr(decimalPlaces: Int): String = roundDecimalPlaces(decimalPlaces).niceStr

private fun Double.isAlmostEquals(other: Double, epsilon: Double = 0.000001): Boolean = (this - other).absoluteValue < epsilon
private fun Float.isAlmostEquals(other: Float, epsilon: Float = 0.000001f): Boolean = (this - other).absoluteValue < epsilon

fun StringBuilder.appendNice(value: Double) {
fun StringBuilder.appendNice(value: Double, zeroSuffix: Boolean): Unit {
when {
round(value).isAlmostEquals(value) -> when {
value >= Int.MIN_VALUE.toDouble() && value <= Int.MAX_VALUE.toDouble() -> append(value.toInt())
else -> append(value.toLong())
}
else -> append(value)
else -> {
append(value)
return
}
}
if (zeroSuffix) append(".0")
}
fun StringBuilder.appendNice(value: Float) {
fun StringBuilder.appendNice(value: Float, zeroSuffix: Boolean): Unit {
when {
round(value).isAlmostEquals(value) -> when {
value >= Int.MIN_VALUE.toFloat() && value <= Int.MAX_VALUE.toFloat() -> append(value.toInt())
else -> append(value.toLong())
}
else -> append(value)
else -> {
append(value)
return
}
}
if (zeroSuffix) append(".0")
}

fun StringBuilder.appendNice(value: Double): Unit = appendNice(value, zeroSuffix = false)
fun StringBuilder.appendNice(value: Float): Unit = appendNice(value, zeroSuffix = false)

//private fun Double.normalizeZero(): Double = if (this.isAlmostZero()) 0.0 else this
private val MINUS_ZERO_D = -0.0
private fun Double.normalizeZero(): Double = if (this == MINUS_ZERO_D) 0.0 else this
Expand Down

0 comments on commit 75e862e

Please sign in to comment.